file_finder.rs

   1use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
   2use fuzzy::PathMatch;
   3use gpui::{
   4    actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
   5};
   6use picker::{Picker, PickerDelegate};
   7use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
   8use std::{
   9    path::Path,
  10    sync::{
  11        atomic::{self, AtomicBool},
  12        Arc,
  13    },
  14};
  15use text::Point;
  16use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
  17use workspace::Workspace;
  18
  19pub type FileFinder = Picker<FileFinderDelegate>;
  20
  21pub struct FileFinderDelegate {
  22    workspace: WeakViewHandle<Workspace>,
  23    project: ModelHandle<Project>,
  24    search_count: usize,
  25    latest_search_id: usize,
  26    latest_search_did_cancel: bool,
  27    latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
  28    currently_opened_path: Option<ProjectPath>,
  29    matches: Vec<PathMatch>,
  30    selected: Option<(usize, Arc<Path>)>,
  31    cancel_flag: Arc<AtomicBool>,
  32    history_items: Vec<ProjectPath>,
  33}
  34
  35actions!(file_finder, [Toggle]);
  36
  37pub fn init(cx: &mut AppContext) {
  38    cx.add_action(toggle_file_finder);
  39    FileFinder::init(cx);
  40}
  41
  42const MAX_RECENT_SELECTIONS: usize = 20;
  43
  44fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
  45    workspace.toggle_modal(cx, |workspace, cx| {
  46        let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx);
  47        let currently_opened_path = workspace
  48            .active_item(cx)
  49            .and_then(|item| item.project_path(cx));
  50
  51        let project = workspace.project().clone();
  52        let workspace = cx.handle().downgrade();
  53        let finder = cx.add_view(|cx| {
  54            Picker::new(
  55                FileFinderDelegate::new(
  56                    workspace,
  57                    project,
  58                    currently_opened_path,
  59                    history_items,
  60                    cx,
  61                ),
  62                cx,
  63            )
  64        });
  65        finder
  66    });
  67}
  68
  69pub enum Event {
  70    Selected(ProjectPath),
  71    Dismissed,
  72}
  73
  74#[derive(Debug, Clone)]
  75struct FileSearchQuery {
  76    raw_query: String,
  77    file_query_end: Option<usize>,
  78}
  79
  80impl FileSearchQuery {
  81    fn path_query(&self) -> &str {
  82        match self.file_query_end {
  83            Some(file_path_end) => &self.raw_query[..file_path_end],
  84            None => &self.raw_query,
  85        }
  86    }
  87}
  88
  89impl FileFinderDelegate {
  90    fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
  91        let path = &path_match.path;
  92        let path_string = path.to_string_lossy();
  93        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
  94        let path_positions = path_match.positions.clone();
  95
  96        let file_name = path.file_name().map_or_else(
  97            || path_match.path_prefix.to_string(),
  98            |file_name| file_name.to_string_lossy().to_string(),
  99        );
 100        let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
 101            - file_name.chars().count();
 102        let file_name_positions = path_positions
 103            .iter()
 104            .filter_map(|pos| {
 105                if pos >= &file_name_start {
 106                    Some(pos - file_name_start)
 107                } else {
 108                    None
 109                }
 110            })
 111            .collect();
 112
 113        (file_name, file_name_positions, full_path, path_positions)
 114    }
 115
 116    pub fn new(
 117        workspace: WeakViewHandle<Workspace>,
 118        project: ModelHandle<Project>,
 119        currently_opened_path: Option<ProjectPath>,
 120        history_items: Vec<ProjectPath>,
 121        cx: &mut ViewContext<FileFinder>,
 122    ) -> Self {
 123        cx.observe(&project, |picker, _, cx| {
 124            picker.update_matches(picker.query(cx), cx);
 125        })
 126        .detach();
 127        Self {
 128            workspace,
 129            project,
 130            search_count: 0,
 131            latest_search_id: 0,
 132            latest_search_did_cancel: false,
 133            latest_search_query: None,
 134            currently_opened_path,
 135            matches: Vec::new(),
 136            selected: None,
 137            cancel_flag: Arc::new(AtomicBool::new(false)),
 138            history_items,
 139        }
 140    }
 141
 142    fn spawn_search(
 143        &mut self,
 144        query: PathLikeWithPosition<FileSearchQuery>,
 145        cx: &mut ViewContext<FileFinder>,
 146    ) -> Task<()> {
 147        let relative_to = self
 148            .currently_opened_path
 149            .as_ref()
 150            .map(|project_path| Arc::clone(&project_path.path));
 151        let worktrees = self
 152            .project
 153            .read(cx)
 154            .visible_worktrees(cx)
 155            .collect::<Vec<_>>();
 156        let include_root_name = worktrees.len() > 1;
 157        let candidate_sets = worktrees
 158            .into_iter()
 159            .map(|worktree| {
 160                let worktree = worktree.read(cx);
 161                PathMatchCandidateSet {
 162                    snapshot: worktree.snapshot(),
 163                    include_ignored: worktree
 164                        .root_entry()
 165                        .map_or(false, |entry| entry.is_ignored),
 166                    include_root_name,
 167                }
 168            })
 169            .collect::<Vec<_>>();
 170
 171        let search_id = util::post_inc(&mut self.search_count);
 172        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
 173        self.cancel_flag = Arc::new(AtomicBool::new(false));
 174        let cancel_flag = self.cancel_flag.clone();
 175        cx.spawn(|picker, mut cx| async move {
 176            let matches = fuzzy::match_path_sets(
 177                candidate_sets.as_slice(),
 178                query.path_like.path_query(),
 179                relative_to,
 180                false,
 181                100,
 182                &cancel_flag,
 183                cx.background(),
 184            )
 185            .await;
 186            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
 187            picker
 188                .update(&mut cx, |picker, cx| {
 189                    picker
 190                        .delegate_mut()
 191                        .set_matches(search_id, did_cancel, query, matches, cx)
 192                })
 193                .log_err();
 194        })
 195    }
 196
 197    fn set_matches(
 198        &mut self,
 199        search_id: usize,
 200        did_cancel: bool,
 201        query: PathLikeWithPosition<FileSearchQuery>,
 202        matches: Vec<PathMatch>,
 203        cx: &mut ViewContext<FileFinder>,
 204    ) {
 205        if search_id >= self.latest_search_id {
 206            self.latest_search_id = search_id;
 207            if self.latest_search_did_cancel
 208                && Some(query.path_like.path_query())
 209                    == self
 210                        .latest_search_query
 211                        .as_ref()
 212                        .map(|query| query.path_like.path_query())
 213            {
 214                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
 215            } else {
 216                self.matches = matches;
 217            }
 218            self.latest_search_query = Some(query);
 219            self.latest_search_did_cancel = did_cancel;
 220            cx.notify();
 221        }
 222    }
 223}
 224
 225impl PickerDelegate for FileFinderDelegate {
 226    fn placeholder_text(&self) -> Arc<str> {
 227        "Search project files...".into()
 228    }
 229
 230    fn match_count(&self) -> usize {
 231        self.matches.len()
 232    }
 233
 234    fn selected_index(&self) -> usize {
 235        if let Some(selected) = self.selected.as_ref() {
 236            for (ix, path_match) in self.matches.iter().enumerate() {
 237                if (path_match.worktree_id, path_match.path.as_ref())
 238                    == (selected.0, selected.1.as_ref())
 239                {
 240                    return ix;
 241                }
 242            }
 243        }
 244        0
 245    }
 246
 247    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
 248        let mat = &self.matches[ix];
 249        self.selected = Some((mat.worktree_id, mat.path.clone()));
 250        cx.notify();
 251    }
 252
 253    fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
 254        if raw_query.is_empty() {
 255            self.latest_search_id = post_inc(&mut self.search_count);
 256            self.matches.clear();
 257
 258            self.matches = self
 259                .currently_opened_path
 260                .iter() // if exists, bubble the currently opened path to the top
 261                .chain(self.history_items.iter().filter(|history_item| {
 262                    Some(*history_item) != self.currently_opened_path.as_ref()
 263                }))
 264                .enumerate()
 265                .map(|(i, history_item)| PathMatch {
 266                    score: i as f64,
 267                    positions: Vec::new(),
 268                    worktree_id: history_item.worktree_id.0,
 269                    path: Arc::clone(&history_item.path),
 270                    path_prefix: "".into(),
 271                    distance_to_relative_ancestor: usize::MAX,
 272                })
 273                .collect();
 274            cx.notify();
 275            Task::ready(())
 276        } else {
 277            let raw_query = &raw_query;
 278            let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
 279                Ok::<_, std::convert::Infallible>(FileSearchQuery {
 280                    raw_query: raw_query.to_owned(),
 281                    file_query_end: if path_like_str == raw_query {
 282                        None
 283                    } else {
 284                        Some(path_like_str.len())
 285                    },
 286                })
 287            })
 288            .expect("infallible");
 289            self.spawn_search(query, cx)
 290        }
 291    }
 292
 293    fn confirm(&mut self, cx: &mut ViewContext<FileFinder>) {
 294        if let Some(m) = self.matches.get(self.selected_index()) {
 295            if let Some(workspace) = self.workspace.upgrade(cx) {
 296                let project_path = ProjectPath {
 297                    worktree_id: WorktreeId::from_usize(m.worktree_id),
 298                    path: m.path.clone(),
 299                };
 300                let open_task = workspace.update(cx, |workspace, cx| {
 301                    workspace.open_path(project_path.clone(), None, true, cx)
 302                });
 303
 304                let workspace = workspace.downgrade();
 305
 306                let row = self
 307                    .latest_search_query
 308                    .as_ref()
 309                    .and_then(|query| query.row)
 310                    .map(|row| row.saturating_sub(1));
 311                let col = self
 312                    .latest_search_query
 313                    .as_ref()
 314                    .and_then(|query| query.column)
 315                    .unwrap_or(0)
 316                    .saturating_sub(1);
 317                cx.spawn(|_, mut cx| async move {
 318                    let item = open_task.await.log_err()?;
 319                    if let Some(row) = row {
 320                        if let Some(active_editor) = item.downcast::<Editor>() {
 321                            active_editor
 322                                .downgrade()
 323                                .update(&mut cx, |editor, cx| {
 324                                    let snapshot = editor.snapshot(cx).display_snapshot;
 325                                    let point = snapshot
 326                                        .buffer_snapshot
 327                                        .clip_point(Point::new(row, col), Bias::Left);
 328                                    editor.change_selections(Some(Autoscroll::center()), cx, |s| {
 329                                        s.select_ranges([point..point])
 330                                    });
 331                                })
 332                                .log_err();
 333                        }
 334                    }
 335                    workspace
 336                        .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
 337                        .log_err();
 338
 339                    Some(())
 340                })
 341                .detach();
 342            }
 343        }
 344    }
 345
 346    fn dismissed(&mut self, _: &mut ViewContext<FileFinder>) {}
 347
 348    fn render_match(
 349        &self,
 350        ix: usize,
 351        mouse_state: &mut MouseState,
 352        selected: bool,
 353        cx: &AppContext,
 354    ) -> AnyElement<Picker<Self>> {
 355        let path_match = &self.matches[ix];
 356        let theme = theme::current(cx);
 357        let style = theme.picker.item.style_for(mouse_state, selected);
 358        let (file_name, file_name_positions, full_path, full_path_positions) =
 359            self.labels_for_match(path_match);
 360        Flex::column()
 361            .with_child(
 362                Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
 363            )
 364            .with_child(
 365                Label::new(full_path, style.label.clone()).with_highlights(full_path_positions),
 366            )
 367            .flex(1., false)
 368            .contained()
 369            .with_style(style.container)
 370            .into_any_named("match")
 371    }
 372}
 373
 374#[cfg(test)]
 375mod tests {
 376    use std::{assert_eq, collections::HashMap, time::Duration};
 377
 378    use super::*;
 379    use editor::Editor;
 380    use gpui::{TestAppContext, ViewHandle};
 381    use menu::{Confirm, SelectNext};
 382    use serde_json::json;
 383    use workspace::{AppState, Pane, Workspace};
 384
 385    #[ctor::ctor]
 386    fn init_logger() {
 387        if std::env::var("RUST_LOG").is_ok() {
 388            env_logger::init();
 389        }
 390    }
 391
 392    #[gpui::test]
 393    async fn test_matching_paths(cx: &mut TestAppContext) {
 394        let app_state = init_test(cx);
 395        app_state
 396            .fs
 397            .as_fake()
 398            .insert_tree(
 399                "/root",
 400                json!({
 401                    "a": {
 402                        "banana": "",
 403                        "bandana": "",
 404                    }
 405                }),
 406            )
 407            .await;
 408
 409        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 410        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 411        cx.dispatch_action(window_id, Toggle);
 412
 413        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
 414        finder
 415            .update(cx, |finder, cx| {
 416                finder.delegate_mut().update_matches("bna".to_string(), cx)
 417            })
 418            .await;
 419        finder.read_with(cx, |finder, _| {
 420            assert_eq!(finder.delegate().matches.len(), 2);
 421        });
 422
 423        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
 424        cx.dispatch_action(window_id, SelectNext);
 425        cx.dispatch_action(window_id, Confirm);
 426        active_pane
 427            .condition(cx, |pane, _| pane.active_item().is_some())
 428            .await;
 429        cx.read(|cx| {
 430            let active_item = active_pane.read(cx).active_item().unwrap();
 431            assert_eq!(
 432                active_item
 433                    .as_any()
 434                    .downcast_ref::<Editor>()
 435                    .unwrap()
 436                    .read(cx)
 437                    .title(cx),
 438                "bandana"
 439            );
 440        });
 441    }
 442
 443    #[gpui::test]
 444    async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
 445        let app_state = init_test(cx);
 446
 447        let first_file_name = "first.rs";
 448        let first_file_contents = "// First Rust file";
 449        app_state
 450            .fs
 451            .as_fake()
 452            .insert_tree(
 453                "/src",
 454                json!({
 455                    "test": {
 456                        first_file_name: first_file_contents,
 457                        "second.rs": "// Second Rust file",
 458                    }
 459                }),
 460            )
 461            .await;
 462
 463        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 464        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 465        cx.dispatch_action(window_id, Toggle);
 466        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
 467
 468        let file_query = &first_file_name[..3];
 469        let file_row = 1;
 470        let file_column = 3;
 471        assert!(file_column <= first_file_contents.len());
 472        let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
 473        finder
 474            .update(cx, |finder, cx| {
 475                finder
 476                    .delegate_mut()
 477                    .update_matches(query_inside_file.to_string(), cx)
 478            })
 479            .await;
 480        finder.read_with(cx, |finder, _| {
 481            let finder = finder.delegate();
 482            assert_eq!(finder.matches.len(), 1);
 483            let latest_search_query = finder
 484                .latest_search_query
 485                .as_ref()
 486                .expect("Finder should have a query after the update_matches call");
 487            assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
 488            assert_eq!(
 489                latest_search_query.path_like.file_query_end,
 490                Some(file_query.len())
 491            );
 492            assert_eq!(latest_search_query.row, Some(file_row));
 493            assert_eq!(latest_search_query.column, Some(file_column as u32));
 494        });
 495
 496        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
 497        cx.dispatch_action(window_id, SelectNext);
 498        cx.dispatch_action(window_id, Confirm);
 499        active_pane
 500            .condition(cx, |pane, _| pane.active_item().is_some())
 501            .await;
 502        let editor = cx.update(|cx| {
 503            let active_item = active_pane.read(cx).active_item().unwrap();
 504            active_item.downcast::<Editor>().unwrap()
 505        });
 506        cx.foreground().advance_clock(Duration::from_secs(2));
 507        cx.foreground().start_waiting();
 508        cx.foreground().finish_waiting();
 509        editor.update(cx, |editor, cx| {
 510            let all_selections = editor.selections.all_adjusted(cx);
 511            assert_eq!(
 512                all_selections.len(),
 513                1,
 514                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 515            );
 516            let caret_selection = all_selections.into_iter().next().unwrap();
 517            assert_eq!(caret_selection.start, caret_selection.end,
 518                "Caret selection should have its start and end at the same position");
 519            assert_eq!(file_row, caret_selection.start.row + 1,
 520                "Query inside file should get caret with the same focus row");
 521            assert_eq!(file_column, caret_selection.start.column as usize + 1,
 522                "Query inside file should get caret with the same focus column");
 523        });
 524    }
 525
 526    #[gpui::test]
 527    async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
 528        let app_state = init_test(cx);
 529
 530        let first_file_name = "first.rs";
 531        let first_file_contents = "// First Rust file";
 532        app_state
 533            .fs
 534            .as_fake()
 535            .insert_tree(
 536                "/src",
 537                json!({
 538                    "test": {
 539                        first_file_name: first_file_contents,
 540                        "second.rs": "// Second Rust file",
 541                    }
 542                }),
 543            )
 544            .await;
 545
 546        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 547        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 548        cx.dispatch_action(window_id, Toggle);
 549        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
 550
 551        let file_query = &first_file_name[..3];
 552        let file_row = 200;
 553        let file_column = 300;
 554        assert!(file_column > first_file_contents.len());
 555        let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
 556        finder
 557            .update(cx, |finder, cx| {
 558                finder
 559                    .delegate_mut()
 560                    .update_matches(query_outside_file.to_string(), cx)
 561            })
 562            .await;
 563        finder.read_with(cx, |finder, _| {
 564            let finder = finder.delegate();
 565            assert_eq!(finder.matches.len(), 1);
 566            let latest_search_query = finder
 567                .latest_search_query
 568                .as_ref()
 569                .expect("Finder should have a query after the update_matches call");
 570            assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
 571            assert_eq!(
 572                latest_search_query.path_like.file_query_end,
 573                Some(file_query.len())
 574            );
 575            assert_eq!(latest_search_query.row, Some(file_row));
 576            assert_eq!(latest_search_query.column, Some(file_column as u32));
 577        });
 578
 579        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
 580        cx.dispatch_action(window_id, SelectNext);
 581        cx.dispatch_action(window_id, Confirm);
 582        active_pane
 583            .condition(cx, |pane, _| pane.active_item().is_some())
 584            .await;
 585        let editor = cx.update(|cx| {
 586            let active_item = active_pane.read(cx).active_item().unwrap();
 587            active_item.downcast::<Editor>().unwrap()
 588        });
 589        cx.foreground().advance_clock(Duration::from_secs(2));
 590        cx.foreground().start_waiting();
 591        cx.foreground().finish_waiting();
 592        editor.update(cx, |editor, cx| {
 593            let all_selections = editor.selections.all_adjusted(cx);
 594            assert_eq!(
 595                all_selections.len(),
 596                1,
 597                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 598            );
 599            let caret_selection = all_selections.into_iter().next().unwrap();
 600            assert_eq!(caret_selection.start, caret_selection.end,
 601                "Caret selection should have its start and end at the same position");
 602            assert_eq!(0, caret_selection.start.row,
 603                "Excessive rows (as in query outside file borders) should get trimmed to last file row");
 604            assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
 605                "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
 606        });
 607    }
 608
 609    #[gpui::test]
 610    async fn test_matching_cancellation(cx: &mut TestAppContext) {
 611        let app_state = init_test(cx);
 612        app_state
 613            .fs
 614            .as_fake()
 615            .insert_tree(
 616                "/dir",
 617                json!({
 618                    "hello": "",
 619                    "goodbye": "",
 620                    "halogen-light": "",
 621                    "happiness": "",
 622                    "height": "",
 623                    "hi": "",
 624                    "hiccup": "",
 625                }),
 626            )
 627            .await;
 628
 629        let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
 630        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 631        let (_, finder) = cx.add_window(|cx| {
 632            Picker::new(
 633                FileFinderDelegate::new(
 634                    workspace.downgrade(),
 635                    workspace.read(cx).project().clone(),
 636                    None,
 637                    Vec::new(),
 638                    cx,
 639                ),
 640                cx,
 641            )
 642        });
 643
 644        let query = test_path_like("hi");
 645        finder
 646            .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
 647            .await;
 648        finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
 649
 650        finder.update(cx, |finder, cx| {
 651            let delegate = finder.delegate_mut();
 652            let matches = delegate.matches.clone();
 653
 654            // Simulate a search being cancelled after the time limit,
 655            // returning only a subset of the matches that would have been found.
 656            drop(delegate.spawn_search(query.clone(), cx));
 657            delegate.set_matches(
 658                delegate.latest_search_id,
 659                true, // did-cancel
 660                query.clone(),
 661                vec![matches[1].clone(), matches[3].clone()],
 662                cx,
 663            );
 664
 665            // Simulate another cancellation.
 666            drop(delegate.spawn_search(query.clone(), cx));
 667            delegate.set_matches(
 668                delegate.latest_search_id,
 669                true, // did-cancel
 670                query.clone(),
 671                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
 672                cx,
 673            );
 674
 675            assert_eq!(delegate.matches, matches[0..4])
 676        });
 677    }
 678
 679    #[gpui::test]
 680    async fn test_ignored_files(cx: &mut TestAppContext) {
 681        let app_state = init_test(cx);
 682        app_state
 683            .fs
 684            .as_fake()
 685            .insert_tree(
 686                "/ancestor",
 687                json!({
 688                    ".gitignore": "ignored-root",
 689                    "ignored-root": {
 690                        "happiness": "",
 691                        "height": "",
 692                        "hi": "",
 693                        "hiccup": "",
 694                    },
 695                    "tracked-root": {
 696                        ".gitignore": "height",
 697                        "happiness": "",
 698                        "height": "",
 699                        "hi": "",
 700                        "hiccup": "",
 701                    },
 702                }),
 703            )
 704            .await;
 705
 706        let project = Project::test(
 707            app_state.fs.clone(),
 708            [
 709                "/ancestor/tracked-root".as_ref(),
 710                "/ancestor/ignored-root".as_ref(),
 711            ],
 712            cx,
 713        )
 714        .await;
 715        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 716        let (_, finder) = cx.add_window(|cx| {
 717            Picker::new(
 718                FileFinderDelegate::new(
 719                    workspace.downgrade(),
 720                    workspace.read(cx).project().clone(),
 721                    None,
 722                    Vec::new(),
 723                    cx,
 724                ),
 725                cx,
 726            )
 727        });
 728        finder
 729            .update(cx, |f, cx| {
 730                f.delegate_mut().spawn_search(test_path_like("hi"), cx)
 731            })
 732            .await;
 733        finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
 734    }
 735
 736    #[gpui::test]
 737    async fn test_single_file_worktrees(cx: &mut TestAppContext) {
 738        let app_state = init_test(cx);
 739        app_state
 740            .fs
 741            .as_fake()
 742            .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
 743            .await;
 744
 745        let project = Project::test(
 746            app_state.fs.clone(),
 747            ["/root/the-parent-dir/the-file".as_ref()],
 748            cx,
 749        )
 750        .await;
 751        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 752        let (_, finder) = cx.add_window(|cx| {
 753            Picker::new(
 754                FileFinderDelegate::new(
 755                    workspace.downgrade(),
 756                    workspace.read(cx).project().clone(),
 757                    None,
 758                    Vec::new(),
 759                    cx,
 760                ),
 761                cx,
 762            )
 763        });
 764
 765        // Even though there is only one worktree, that worktree's filename
 766        // is included in the matching, because the worktree is a single file.
 767        finder
 768            .update(cx, |f, cx| {
 769                f.delegate_mut().spawn_search(test_path_like("thf"), cx)
 770            })
 771            .await;
 772        cx.read(|cx| {
 773            let finder = finder.read(cx);
 774            let delegate = finder.delegate();
 775            assert_eq!(delegate.matches.len(), 1);
 776
 777            let (file_name, file_name_positions, full_path, full_path_positions) =
 778                delegate.labels_for_match(&delegate.matches[0]);
 779            assert_eq!(file_name, "the-file");
 780            assert_eq!(file_name_positions, &[0, 1, 4]);
 781            assert_eq!(full_path, "the-file");
 782            assert_eq!(full_path_positions, &[0, 1, 4]);
 783        });
 784
 785        // Since the worktree root is a file, searching for its name followed by a slash does
 786        // not match anything.
 787        finder
 788            .update(cx, |f, cx| {
 789                f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
 790            })
 791            .await;
 792        finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
 793    }
 794
 795    #[gpui::test]
 796    async fn test_multiple_matches_with_same_relative_path(cx: &mut TestAppContext) {
 797        let app_state = init_test(cx);
 798        app_state
 799            .fs
 800            .as_fake()
 801            .insert_tree(
 802                "/root",
 803                json!({
 804                    "dir1": { "a.txt": "" },
 805                    "dir2": { "a.txt": "" }
 806                }),
 807            )
 808            .await;
 809
 810        let project = Project::test(
 811            app_state.fs.clone(),
 812            ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
 813            cx,
 814        )
 815        .await;
 816        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 817
 818        let (_, finder) = cx.add_window(|cx| {
 819            Picker::new(
 820                FileFinderDelegate::new(
 821                    workspace.downgrade(),
 822                    workspace.read(cx).project().clone(),
 823                    None,
 824                    Vec::new(),
 825                    cx,
 826                ),
 827                cx,
 828            )
 829        });
 830
 831        // Run a search that matches two files with the same relative path.
 832        finder
 833            .update(cx, |f, cx| {
 834                f.delegate_mut().spawn_search(test_path_like("a.t"), cx)
 835            })
 836            .await;
 837
 838        // Can switch between different matches with the same relative path.
 839        finder.update(cx, |finder, cx| {
 840            let delegate = finder.delegate_mut();
 841            assert_eq!(delegate.matches.len(), 2);
 842            assert_eq!(delegate.selected_index(), 0);
 843            delegate.set_selected_index(1, cx);
 844            assert_eq!(delegate.selected_index(), 1);
 845            delegate.set_selected_index(0, cx);
 846            assert_eq!(delegate.selected_index(), 0);
 847        });
 848    }
 849
 850    #[gpui::test]
 851    async fn test_path_distance_ordering(cx: &mut TestAppContext) {
 852        let app_state = init_test(cx);
 853        app_state
 854            .fs
 855            .as_fake()
 856            .insert_tree(
 857                "/root",
 858                json!({
 859                    "dir1": { "a.txt": "" },
 860                    "dir2": {
 861                        "a.txt": "",
 862                        "b.txt": ""
 863                    }
 864                }),
 865            )
 866            .await;
 867
 868        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 869        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 870        let worktree_id = cx.read(|cx| {
 871            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 872            assert_eq!(worktrees.len(), 1);
 873            WorktreeId(worktrees[0].id())
 874        });
 875
 876        // When workspace has an active item, sort items which are closer to that item
 877        // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
 878        // so that one should be sorted earlier
 879        let b_path = Some(ProjectPath {
 880            worktree_id,
 881            path: Arc::from(Path::new("/root/dir2/b.txt")),
 882        });
 883        let (_, finder) = cx.add_window(|cx| {
 884            Picker::new(
 885                FileFinderDelegate::new(
 886                    workspace.downgrade(),
 887                    workspace.read(cx).project().clone(),
 888                    b_path,
 889                    Vec::new(),
 890                    cx,
 891                ),
 892                cx,
 893            )
 894        });
 895
 896        finder
 897            .update(cx, |f, cx| {
 898                f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
 899            })
 900            .await;
 901
 902        finder.read_with(cx, |f, _| {
 903            let delegate = f.delegate();
 904            assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
 905            assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
 906        });
 907    }
 908
 909    #[gpui::test]
 910    async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
 911        let app_state = init_test(cx);
 912        app_state
 913            .fs
 914            .as_fake()
 915            .insert_tree(
 916                "/root",
 917                json!({
 918                    "dir1": {},
 919                    "dir2": {
 920                        "dir3": {}
 921                    }
 922                }),
 923            )
 924            .await;
 925
 926        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 927        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 928        let (_, finder) = cx.add_window(|cx| {
 929            Picker::new(
 930                FileFinderDelegate::new(
 931                    workspace.downgrade(),
 932                    workspace.read(cx).project().clone(),
 933                    None,
 934                    Vec::new(),
 935                    cx,
 936                ),
 937                cx,
 938            )
 939        });
 940        finder
 941            .update(cx, |f, cx| {
 942                f.delegate_mut().spawn_search(test_path_like("dir"), cx)
 943            })
 944            .await;
 945        cx.read(|cx| {
 946            let finder = finder.read(cx);
 947            assert_eq!(finder.delegate().matches.len(), 0);
 948        });
 949    }
 950
 951    #[gpui::test]
 952    async fn test_query_history(
 953        deterministic: Arc<gpui::executor::Deterministic>,
 954        cx: &mut gpui::TestAppContext,
 955    ) {
 956        let app_state = init_test(cx);
 957
 958        app_state
 959            .fs
 960            .as_fake()
 961            .insert_tree(
 962                "/src",
 963                json!({
 964                    "test": {
 965                        "first.rs": "// First Rust file",
 966                        "second.rs": "// Second Rust file",
 967                        "third.rs": "// Third Rust file",
 968                    }
 969                }),
 970            )
 971            .await;
 972
 973        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 974        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
 975        let worktree_id = cx.read(|cx| {
 976            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 977            assert_eq!(worktrees.len(), 1);
 978            WorktreeId(worktrees[0].id())
 979        });
 980
 981        // Open and close panels, getting their history items afterwards.
 982        // Ensure history items get populated with opened items, and items are kept in a certain order.
 983        // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
 984        //
 985        // TODO: without closing, the opened items do not propagate their history changes for some reason
 986        // it does work in real app though, only tests do not propagate.
 987
 988        let initial_history = open_close_queried_buffer(
 989            "fir",
 990            1,
 991            "first.rs",
 992            window_id,
 993            &workspace,
 994            &deterministic,
 995            cx,
 996        )
 997        .await;
 998        assert!(
 999            initial_history.is_empty(),
1000            "Should have no history before opening any files"
1001        );
1002
1003        let history_after_first = open_close_queried_buffer(
1004            "sec",
1005            1,
1006            "second.rs",
1007            window_id,
1008            &workspace,
1009            &deterministic,
1010            cx,
1011        )
1012        .await;
1013        assert_eq!(
1014            history_after_first,
1015            vec![ProjectPath {
1016                worktree_id,
1017                path: Arc::from(Path::new("test/first.rs")),
1018            }],
1019            "Should show 1st opened item in the history when opening the 2nd item"
1020        );
1021
1022        let history_after_second = open_close_queried_buffer(
1023            "thi",
1024            1,
1025            "third.rs",
1026            window_id,
1027            &workspace,
1028            &deterministic,
1029            cx,
1030        )
1031        .await;
1032        assert_eq!(
1033            history_after_second,
1034            vec![
1035                ProjectPath {
1036                    worktree_id,
1037                    path: Arc::from(Path::new("test/second.rs")),
1038                },
1039                ProjectPath {
1040                    worktree_id,
1041                    path: Arc::from(Path::new("test/first.rs")),
1042                },
1043            ],
1044            "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
10452nd item should be the first in the history, as the last opened."
1046        );
1047
1048        let history_after_third = open_close_queried_buffer(
1049            "sec",
1050            1,
1051            "second.rs",
1052            window_id,
1053            &workspace,
1054            &deterministic,
1055            cx,
1056        )
1057        .await;
1058        assert_eq!(
1059            history_after_third,
1060            vec![
1061                ProjectPath {
1062                    worktree_id,
1063                    path: Arc::from(Path::new("test/third.rs")),
1064                },
1065                ProjectPath {
1066                    worktree_id,
1067                    path: Arc::from(Path::new("test/second.rs")),
1068                },
1069                ProjectPath {
1070                    worktree_id,
1071                    path: Arc::from(Path::new("test/first.rs")),
1072                },
1073            ],
1074            "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
10753rd item should be the first in the history, as the last opened."
1076        );
1077
1078        let history_after_second_again = open_close_queried_buffer(
1079            "thi",
1080            1,
1081            "third.rs",
1082            window_id,
1083            &workspace,
1084            &deterministic,
1085            cx,
1086        )
1087        .await;
1088        assert_eq!(
1089            history_after_second_again,
1090            vec![
1091                ProjectPath {
1092                    worktree_id,
1093                    path: Arc::from(Path::new("test/second.rs")),
1094                },
1095                ProjectPath {
1096                    worktree_id,
1097                    path: Arc::from(Path::new("test/third.rs")),
1098                },
1099                ProjectPath {
1100                    worktree_id,
1101                    path: Arc::from(Path::new("test/first.rs")),
1102                },
1103            ],
1104            "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
11052nd item, as the last opened, 3rd item should go next as it was opened right before."
1106        );
1107    }
1108
1109    async fn open_close_queried_buffer(
1110        input: &str,
1111        expected_matches: usize,
1112        expected_editor_title: &str,
1113        window_id: usize,
1114        workspace: &ViewHandle<Workspace>,
1115        deterministic: &gpui::executor::Deterministic,
1116        cx: &mut gpui::TestAppContext,
1117    ) -> Vec<ProjectPath> {
1118        cx.dispatch_action(window_id, Toggle);
1119        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1120        finder
1121            .update(cx, |finder, cx| {
1122                finder.delegate_mut().update_matches(input.to_string(), cx)
1123            })
1124            .await;
1125        let history_items = finder.read_with(cx, |finder, _| {
1126            assert_eq!(
1127                finder.delegate().matches.len(),
1128                expected_matches,
1129                "Unexpected number of matches found for query {input}"
1130            );
1131            finder.delegate().history_items.clone()
1132        });
1133
1134        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
1135        cx.dispatch_action(window_id, SelectNext);
1136        cx.dispatch_action(window_id, Confirm);
1137        deterministic.run_until_parked();
1138        active_pane
1139            .condition(cx, |pane, _| pane.active_item().is_some())
1140            .await;
1141        cx.read(|cx| {
1142            let active_item = active_pane.read(cx).active_item().unwrap();
1143            let active_editor_title = active_item
1144                .as_any()
1145                .downcast_ref::<Editor>()
1146                .unwrap()
1147                .read(cx)
1148                .title(cx);
1149            assert_eq!(
1150                expected_editor_title, active_editor_title,
1151                "Unexpected editor title for query {input}"
1152            );
1153        });
1154
1155        let mut original_items = HashMap::new();
1156        cx.read(|cx| {
1157            for pane in workspace.read(cx).panes() {
1158                let pane_id = pane.id();
1159                let pane = pane.read(cx);
1160                let insertion_result = original_items.insert(pane_id, pane.items().count());
1161                assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
1162            }
1163        });
1164        workspace.update(cx, |workspace, cx| {
1165            Pane::close_active_item(workspace, &workspace::CloseActiveItem, cx);
1166        });
1167        deterministic.run_until_parked();
1168        cx.read(|cx| {
1169            for pane in workspace.read(cx).panes() {
1170                let pane_id = pane.id();
1171                let pane = pane.read(cx);
1172                match original_items.remove(&pane_id) {
1173                    Some(original_items) => {
1174                        assert_eq!(
1175                            pane.items().count(),
1176                            original_items.saturating_sub(1),
1177                            "Pane id {pane_id} should have item closed"
1178                        );
1179                    }
1180                    None => panic!("Pane id {pane_id} not found in original items"),
1181                }
1182            }
1183        });
1184
1185        history_items
1186    }
1187
1188    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1189        cx.foreground().forbid_parking();
1190        cx.update(|cx| {
1191            let state = AppState::test(cx);
1192            theme::init((), cx);
1193            language::init(cx);
1194            super::init(cx);
1195            editor::init(cx);
1196            workspace::init_settings(cx);
1197            state
1198        })
1199    }
1200
1201    fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1202        PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1203            Ok::<_, std::convert::Infallible>(FileSearchQuery {
1204                raw_query: test_str.to_owned(),
1205                file_query_end: if path_like_str == test_str {
1206                    None
1207                } else {
1208                    Some(path_like_str.len())
1209                },
1210            })
1211        })
1212        .unwrap()
1213    }
1214}