file_finder_tests.rs

   1use std::{assert_eq, path::Path, time::Duration};
   2
   3use super::*;
   4use editor::Editor;
   5use gpui::{Entity, TestAppContext, VisualTestContext};
   6use menu::{Confirm, SelectNext};
   7use serde_json::json;
   8use workspace::{AppState, Workspace};
   9
  10#[ctor::ctor]
  11fn init_logger() {
  12    if std::env::var("RUST_LOG").is_ok() {
  13        env_logger::init();
  14    }
  15}
  16
  17#[gpui::test]
  18async fn test_matching_paths(cx: &mut TestAppContext) {
  19    let app_state = init_test(cx);
  20    app_state
  21        .fs
  22        .as_fake()
  23        .insert_tree(
  24            "/root",
  25            json!({
  26                "a": {
  27                    "banana": "",
  28                    "bandana": "",
  29                }
  30            }),
  31        )
  32        .await;
  33
  34    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
  35
  36    let (picker, workspace, cx) = build_find_picker(project, cx);
  37
  38    cx.simulate_input("bna");
  39    picker.update(cx, |picker, _| {
  40        assert_eq!(picker.delegate.matches.len(), 2);
  41    });
  42    cx.dispatch_action(SelectNext);
  43    cx.dispatch_action(Confirm);
  44    cx.read(|cx| {
  45        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
  46        assert_eq!(active_editor.read(cx).title(cx), "bandana");
  47    });
  48
  49    for bandana_query in [
  50        "bandana",
  51        " bandana",
  52        "bandana ",
  53        " bandana ",
  54        " ndan ",
  55        " band ",
  56    ] {
  57        picker
  58            .update(cx, |picker, cx| {
  59                picker
  60                    .delegate
  61                    .update_matches(bandana_query.to_string(), cx)
  62            })
  63            .await;
  64        picker.update(cx, |picker, _| {
  65            assert_eq!(
  66                picker.delegate.matches.len(),
  67                1,
  68                "Wrong number of matches for bandana query '{bandana_query}'"
  69            );
  70        });
  71        cx.dispatch_action(SelectNext);
  72        cx.dispatch_action(Confirm);
  73        cx.read(|cx| {
  74            let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
  75            assert_eq!(
  76                active_editor.read(cx).title(cx),
  77                "bandana",
  78                "Wrong match for bandana query '{bandana_query}'"
  79            );
  80        });
  81    }
  82}
  83
  84#[gpui::test]
  85async fn test_absolute_paths(cx: &mut TestAppContext) {
  86    let app_state = init_test(cx);
  87    app_state
  88        .fs
  89        .as_fake()
  90        .insert_tree(
  91            "/root",
  92            json!({
  93                "a": {
  94                    "file1.txt": "",
  95                    "b": {
  96                        "file2.txt": "",
  97                    },
  98                }
  99            }),
 100        )
 101        .await;
 102
 103    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 104
 105    let (picker, workspace, cx) = build_find_picker(project, cx);
 106
 107    let matching_abs_path = "/root/a/b/file2.txt";
 108    picker
 109        .update(cx, |picker, cx| {
 110            picker
 111                .delegate
 112                .update_matches(matching_abs_path.to_string(), cx)
 113        })
 114        .await;
 115    picker.update(cx, |picker, _| {
 116        assert_eq!(
 117            collect_search_matches(picker).search_only(),
 118            vec![PathBuf::from("a/b/file2.txt")],
 119            "Matching abs path should be the only match"
 120        )
 121    });
 122    cx.dispatch_action(SelectNext);
 123    cx.dispatch_action(Confirm);
 124    cx.read(|cx| {
 125        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 126        assert_eq!(active_editor.read(cx).title(cx), "file2.txt");
 127    });
 128
 129    let mismatching_abs_path = "/root/a/b/file1.txt";
 130    picker
 131        .update(cx, |picker, cx| {
 132            picker
 133                .delegate
 134                .update_matches(mismatching_abs_path.to_string(), cx)
 135        })
 136        .await;
 137    picker.update(cx, |picker, _| {
 138        assert_eq!(
 139            collect_search_matches(picker).search_only(),
 140            Vec::<PathBuf>::new(),
 141            "Mismatching abs path should produce no matches"
 142        )
 143    });
 144}
 145
 146#[gpui::test]
 147async fn test_complex_path(cx: &mut TestAppContext) {
 148    let app_state = init_test(cx);
 149    app_state
 150        .fs
 151        .as_fake()
 152        .insert_tree(
 153            "/root",
 154            json!({
 155                "其他": {
 156                    "S数据表格": {
 157                        "task.xlsx": "some content",
 158                    },
 159                }
 160            }),
 161        )
 162        .await;
 163
 164    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 165
 166    let (picker, workspace, cx) = build_find_picker(project, cx);
 167
 168    cx.simulate_input("t");
 169    picker.update(cx, |picker, _| {
 170        assert_eq!(picker.delegate.matches.len(), 1);
 171        assert_eq!(
 172            collect_search_matches(picker).search_only(),
 173            vec![PathBuf::from("其他/S数据表格/task.xlsx")],
 174        )
 175    });
 176    cx.dispatch_action(SelectNext);
 177    cx.dispatch_action(Confirm);
 178    cx.read(|cx| {
 179        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
 180        assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
 181    });
 182}
 183
 184#[gpui::test]
 185async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
 186    let app_state = init_test(cx);
 187
 188    let first_file_name = "first.rs";
 189    let first_file_contents = "// First Rust file";
 190    app_state
 191        .fs
 192        .as_fake()
 193        .insert_tree(
 194            "/src",
 195            json!({
 196                "test": {
 197                    first_file_name: first_file_contents,
 198                    "second.rs": "// Second Rust file",
 199                }
 200            }),
 201        )
 202        .await;
 203
 204    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 205
 206    let (picker, workspace, cx) = build_find_picker(project, cx);
 207
 208    let file_query = &first_file_name[..3];
 209    let file_row = 1;
 210    let file_column = 3;
 211    assert!(file_column <= first_file_contents.len());
 212    let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
 213    picker
 214        .update(cx, |finder, cx| {
 215            finder
 216                .delegate
 217                .update_matches(query_inside_file.to_string(), cx)
 218        })
 219        .await;
 220    picker.update(cx, |finder, _| {
 221        let finder = &finder.delegate;
 222        assert_eq!(finder.matches.len(), 1);
 223        let latest_search_query = finder
 224            .latest_search_query
 225            .as_ref()
 226            .expect("Finder should have a query after the update_matches call");
 227        assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
 228        assert_eq!(
 229            latest_search_query.path_like.file_query_end,
 230            Some(file_query.len())
 231        );
 232        assert_eq!(latest_search_query.row, Some(file_row));
 233        assert_eq!(latest_search_query.column, Some(file_column as u32));
 234    });
 235
 236    cx.dispatch_action(SelectNext);
 237    cx.dispatch_action(Confirm);
 238
 239    let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 240    cx.executor().advance_clock(Duration::from_secs(2));
 241
 242    editor.update(cx, |editor, cx| {
 243            let all_selections = editor.selections.all_adjusted(cx);
 244            assert_eq!(
 245                all_selections.len(),
 246                1,
 247                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 248            );
 249            let caret_selection = all_selections.into_iter().next().unwrap();
 250            assert_eq!(caret_selection.start, caret_selection.end,
 251                "Caret selection should have its start and end at the same position");
 252            assert_eq!(file_row, caret_selection.start.row + 1,
 253                "Query inside file should get caret with the same focus row");
 254            assert_eq!(file_column, caret_selection.start.column as usize + 1,
 255                "Query inside file should get caret with the same focus column");
 256        });
 257}
 258
 259#[gpui::test]
 260async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
 261    let app_state = init_test(cx);
 262
 263    let first_file_name = "first.rs";
 264    let first_file_contents = "// First Rust file";
 265    app_state
 266        .fs
 267        .as_fake()
 268        .insert_tree(
 269            "/src",
 270            json!({
 271                "test": {
 272                    first_file_name: first_file_contents,
 273                    "second.rs": "// Second Rust file",
 274                }
 275            }),
 276        )
 277        .await;
 278
 279    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 280
 281    let (picker, workspace, cx) = build_find_picker(project, cx);
 282
 283    let file_query = &first_file_name[..3];
 284    let file_row = 200;
 285    let file_column = 300;
 286    assert!(file_column > first_file_contents.len());
 287    let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
 288    picker
 289        .update(cx, |picker, cx| {
 290            picker
 291                .delegate
 292                .update_matches(query_outside_file.to_string(), cx)
 293        })
 294        .await;
 295    picker.update(cx, |finder, _| {
 296        let delegate = &finder.delegate;
 297        assert_eq!(delegate.matches.len(), 1);
 298        let latest_search_query = delegate
 299            .latest_search_query
 300            .as_ref()
 301            .expect("Finder should have a query after the update_matches call");
 302        assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
 303        assert_eq!(
 304            latest_search_query.path_like.file_query_end,
 305            Some(file_query.len())
 306        );
 307        assert_eq!(latest_search_query.row, Some(file_row));
 308        assert_eq!(latest_search_query.column, Some(file_column as u32));
 309    });
 310
 311    cx.dispatch_action(SelectNext);
 312    cx.dispatch_action(Confirm);
 313
 314    let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
 315    cx.executor().advance_clock(Duration::from_secs(2));
 316
 317    editor.update(cx, |editor, cx| {
 318            let all_selections = editor.selections.all_adjusted(cx);
 319            assert_eq!(
 320                all_selections.len(),
 321                1,
 322                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
 323            );
 324            let caret_selection = all_selections.into_iter().next().unwrap();
 325            assert_eq!(caret_selection.start, caret_selection.end,
 326                "Caret selection should have its start and end at the same position");
 327            assert_eq!(0, caret_selection.start.row,
 328                "Excessive rows (as in query outside file borders) should get trimmed to last file row");
 329            assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
 330                "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
 331        });
 332}
 333
 334#[gpui::test]
 335async fn test_matching_cancellation(cx: &mut TestAppContext) {
 336    let app_state = init_test(cx);
 337    app_state
 338        .fs
 339        .as_fake()
 340        .insert_tree(
 341            "/dir",
 342            json!({
 343                "hello": "",
 344                "goodbye": "",
 345                "halogen-light": "",
 346                "happiness": "",
 347                "height": "",
 348                "hi": "",
 349                "hiccup": "",
 350            }),
 351        )
 352        .await;
 353
 354    let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
 355
 356    let (picker, _, cx) = build_find_picker(project, cx);
 357
 358    let query = test_path_like("hi");
 359    picker
 360        .update(cx, |picker, cx| {
 361            picker.delegate.spawn_search(query.clone(), cx)
 362        })
 363        .await;
 364
 365    picker.update(cx, |picker, _cx| {
 366        assert_eq!(picker.delegate.matches.len(), 5)
 367    });
 368
 369    picker.update(cx, |picker, cx| {
 370        let delegate = &mut picker.delegate;
 371        assert!(
 372            delegate.matches.history.is_empty(),
 373            "Search matches expected"
 374        );
 375        let matches = delegate.matches.search.clone();
 376
 377        // Simulate a search being cancelled after the time limit,
 378        // returning only a subset of the matches that would have been found.
 379        drop(delegate.spawn_search(query.clone(), cx));
 380        delegate.set_search_matches(
 381            delegate.latest_search_id,
 382            true, // did-cancel
 383            query.clone(),
 384            vec![matches[1].clone(), matches[3].clone()],
 385            cx,
 386        );
 387
 388        // Simulate another cancellation.
 389        drop(delegate.spawn_search(query.clone(), cx));
 390        delegate.set_search_matches(
 391            delegate.latest_search_id,
 392            true, // did-cancel
 393            query.clone(),
 394            vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
 395            cx,
 396        );
 397
 398        assert!(
 399            delegate.matches.history.is_empty(),
 400            "Search matches expected"
 401        );
 402        assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
 403    });
 404}
 405
 406#[gpui::test]
 407async fn test_ignored_root(cx: &mut TestAppContext) {
 408    let app_state = init_test(cx);
 409    app_state
 410        .fs
 411        .as_fake()
 412        .insert_tree(
 413            "/ancestor",
 414            json!({
 415                ".gitignore": "ignored-root",
 416                "ignored-root": {
 417                    "happiness": "",
 418                    "height": "",
 419                    "hi": "",
 420                    "hiccup": "",
 421                },
 422                "tracked-root": {
 423                    ".gitignore": "height",
 424                    "happiness": "",
 425                    "height": "",
 426                    "hi": "",
 427                    "hiccup": "",
 428                },
 429            }),
 430        )
 431        .await;
 432
 433    let project = Project::test(
 434        app_state.fs.clone(),
 435        [
 436            "/ancestor/tracked-root".as_ref(),
 437            "/ancestor/ignored-root".as_ref(),
 438        ],
 439        cx,
 440    )
 441    .await;
 442
 443    let (picker, _, cx) = build_find_picker(project, cx);
 444
 445    picker
 446        .update(cx, |picker, cx| {
 447            picker.delegate.spawn_search(test_path_like("hi"), cx)
 448        })
 449        .await;
 450    picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7));
 451}
 452
 453#[gpui::test]
 454async fn test_single_file_worktrees(cx: &mut TestAppContext) {
 455    let app_state = init_test(cx);
 456    app_state
 457        .fs
 458        .as_fake()
 459        .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
 460        .await;
 461
 462    let project = Project::test(
 463        app_state.fs.clone(),
 464        ["/root/the-parent-dir/the-file".as_ref()],
 465        cx,
 466    )
 467    .await;
 468
 469    let (picker, _, cx) = build_find_picker(project, cx);
 470
 471    // Even though there is only one worktree, that worktree's filename
 472    // is included in the matching, because the worktree is a single file.
 473    picker
 474        .update(cx, |picker, cx| {
 475            picker.delegate.spawn_search(test_path_like("thf"), cx)
 476        })
 477        .await;
 478    cx.read(|cx| {
 479        let picker = picker.read(cx);
 480        let delegate = &picker.delegate;
 481        assert!(
 482            delegate.matches.history.is_empty(),
 483            "Search matches expected"
 484        );
 485        let matches = delegate.matches.search.clone();
 486        assert_eq!(matches.len(), 1);
 487
 488        let (file_name, file_name_positions, full_path, full_path_positions) =
 489            delegate.labels_for_path_match(&matches[0].0);
 490        assert_eq!(file_name, "the-file");
 491        assert_eq!(file_name_positions, &[0, 1, 4]);
 492        assert_eq!(full_path, "the-file");
 493        assert_eq!(full_path_positions, &[0, 1, 4]);
 494    });
 495
 496    // Since the worktree root is a file, searching for its name followed by a slash does
 497    // not match anything.
 498    picker
 499        .update(cx, |f, cx| {
 500            f.delegate.spawn_search(test_path_like("thf/"), cx)
 501        })
 502        .await;
 503    picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
 504}
 505
 506#[gpui::test]
 507async fn test_path_distance_ordering(cx: &mut TestAppContext) {
 508    let app_state = init_test(cx);
 509    app_state
 510        .fs
 511        .as_fake()
 512        .insert_tree(
 513            "/root",
 514            json!({
 515                "dir1": { "a.txt": "" },
 516                "dir2": {
 517                    "a.txt": "",
 518                    "b.txt": ""
 519                }
 520            }),
 521        )
 522        .await;
 523
 524    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 525    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 526
 527    let worktree_id = cx.read(|cx| {
 528        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 529        assert_eq!(worktrees.len(), 1);
 530        WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
 531    });
 532
 533    // When workspace has an active item, sort items which are closer to that item
 534    // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
 535    // so that one should be sorted earlier
 536    let b_path = ProjectPath {
 537        worktree_id,
 538        path: Arc::from(Path::new("dir2/b.txt")),
 539    };
 540    workspace
 541        .update(cx, |workspace, cx| {
 542            workspace.open_path(b_path, None, true, cx)
 543        })
 544        .await
 545        .unwrap();
 546    let finder = open_file_picker(&workspace, cx);
 547    finder
 548        .update(cx, |f, cx| {
 549            f.delegate.spawn_search(test_path_like("a.txt"), cx)
 550        })
 551        .await;
 552
 553    finder.update(cx, |f, _| {
 554        let delegate = &f.delegate;
 555        assert!(
 556            delegate.matches.history.is_empty(),
 557            "Search matches expected"
 558        );
 559        let matches = &delegate.matches.search;
 560        assert_eq!(matches[0].0.path.as_ref(), Path::new("dir2/a.txt"));
 561        assert_eq!(matches[1].0.path.as_ref(), Path::new("dir1/a.txt"));
 562    });
 563}
 564
 565#[gpui::test]
 566async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
 567    let app_state = init_test(cx);
 568    app_state
 569        .fs
 570        .as_fake()
 571        .insert_tree(
 572            "/root",
 573            json!({
 574                "dir1": {},
 575                "dir2": {
 576                    "dir3": {}
 577                }
 578            }),
 579        )
 580        .await;
 581
 582    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
 583    let (picker, _workspace, cx) = build_find_picker(project, cx);
 584
 585    picker
 586        .update(cx, |f, cx| {
 587            f.delegate.spawn_search(test_path_like("dir"), cx)
 588        })
 589        .await;
 590    cx.read(|cx| {
 591        let finder = picker.read(cx);
 592        assert_eq!(finder.delegate.matches.len(), 0);
 593    });
 594}
 595
 596#[gpui::test]
 597async fn test_query_history(cx: &mut gpui::TestAppContext) {
 598    let app_state = init_test(cx);
 599
 600    app_state
 601        .fs
 602        .as_fake()
 603        .insert_tree(
 604            "/src",
 605            json!({
 606                "test": {
 607                    "first.rs": "// First Rust file",
 608                    "second.rs": "// Second Rust file",
 609                    "third.rs": "// Third Rust file",
 610                }
 611            }),
 612        )
 613        .await;
 614
 615    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 616    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 617    let worktree_id = cx.read(|cx| {
 618        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 619        assert_eq!(worktrees.len(), 1);
 620        WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
 621    });
 622
 623    // Open and close panels, getting their history items afterwards.
 624    // Ensure history items get populated with opened items, and items are kept in a certain order.
 625    // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
 626    //
 627    // TODO: without closing, the opened items do not propagate their history changes for some reason
 628    // it does work in real app though, only tests do not propagate.
 629    workspace.update(cx, |_, cx| cx.focused());
 630
 631    let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
 632    assert!(
 633        initial_history.is_empty(),
 634        "Should have no history before opening any files"
 635    );
 636
 637    let history_after_first =
 638        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 639    assert_eq!(
 640        history_after_first,
 641        vec![FoundPath::new(
 642            ProjectPath {
 643                worktree_id,
 644                path: Arc::from(Path::new("test/first.rs")),
 645            },
 646            Some(PathBuf::from("/src/test/first.rs"))
 647        )],
 648        "Should show 1st opened item in the history when opening the 2nd item"
 649    );
 650
 651    let history_after_second =
 652        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
 653    assert_eq!(
 654        history_after_second,
 655        vec![
 656            FoundPath::new(
 657                ProjectPath {
 658                    worktree_id,
 659                    path: Arc::from(Path::new("test/second.rs")),
 660                },
 661                Some(PathBuf::from("/src/test/second.rs"))
 662            ),
 663            FoundPath::new(
 664                ProjectPath {
 665                    worktree_id,
 666                    path: Arc::from(Path::new("test/first.rs")),
 667                },
 668                Some(PathBuf::from("/src/test/first.rs"))
 669            ),
 670        ],
 671        "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
 672    2nd item should be the first in the history, as the last opened."
 673    );
 674
 675    let history_after_third =
 676        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 677    assert_eq!(
 678                history_after_third,
 679                vec![
 680                    FoundPath::new(
 681                        ProjectPath {
 682                            worktree_id,
 683                            path: Arc::from(Path::new("test/third.rs")),
 684                        },
 685                        Some(PathBuf::from("/src/test/third.rs"))
 686                    ),
 687                    FoundPath::new(
 688                        ProjectPath {
 689                            worktree_id,
 690                            path: Arc::from(Path::new("test/second.rs")),
 691                        },
 692                        Some(PathBuf::from("/src/test/second.rs"))
 693                    ),
 694                    FoundPath::new(
 695                        ProjectPath {
 696                            worktree_id,
 697                            path: Arc::from(Path::new("test/first.rs")),
 698                        },
 699                        Some(PathBuf::from("/src/test/first.rs"))
 700                    ),
 701                ],
 702                "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
 703    3rd item should be the first in the history, as the last opened."
 704            );
 705
 706    let history_after_second_again =
 707        open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
 708    assert_eq!(
 709                history_after_second_again,
 710                vec![
 711                    FoundPath::new(
 712                        ProjectPath {
 713                            worktree_id,
 714                            path: Arc::from(Path::new("test/second.rs")),
 715                        },
 716                        Some(PathBuf::from("/src/test/second.rs"))
 717                    ),
 718                    FoundPath::new(
 719                        ProjectPath {
 720                            worktree_id,
 721                            path: Arc::from(Path::new("test/third.rs")),
 722                        },
 723                        Some(PathBuf::from("/src/test/third.rs"))
 724                    ),
 725                    FoundPath::new(
 726                        ProjectPath {
 727                            worktree_id,
 728                            path: Arc::from(Path::new("test/first.rs")),
 729                        },
 730                        Some(PathBuf::from("/src/test/first.rs"))
 731                    ),
 732                ],
 733                "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
 734    2nd item, as the last opened, 3rd item should go next as it was opened right before."
 735            );
 736}
 737
 738#[gpui::test]
 739async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
 740    let app_state = init_test(cx);
 741
 742    app_state
 743        .fs
 744        .as_fake()
 745        .insert_tree(
 746            "/src",
 747            json!({
 748                "test": {
 749                    "first.rs": "// First Rust file",
 750                    "second.rs": "// Second Rust file",
 751                }
 752            }),
 753        )
 754        .await;
 755
 756    app_state
 757        .fs
 758        .as_fake()
 759        .insert_tree(
 760            "/external-src",
 761            json!({
 762                "test": {
 763                    "third.rs": "// Third Rust file",
 764                    "fourth.rs": "// Fourth Rust file",
 765                }
 766            }),
 767        )
 768        .await;
 769
 770    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 771    cx.update(|cx| {
 772        project.update(cx, |project, cx| {
 773            project.find_or_create_local_worktree("/external-src", false, cx)
 774        })
 775    })
 776    .detach();
 777    cx.background_executor.run_until_parked();
 778
 779    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 780    let worktree_id = cx.read(|cx| {
 781        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 782        assert_eq!(worktrees.len(), 1,);
 783
 784        WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
 785    });
 786    workspace
 787        .update(cx, |workspace, cx| {
 788            workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
 789        })
 790        .detach();
 791    cx.background_executor.run_until_parked();
 792    let external_worktree_id = cx.read(|cx| {
 793        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 794        assert_eq!(
 795            worktrees.len(),
 796            2,
 797            "External file should get opened in a new worktree"
 798        );
 799
 800        WorktreeId::from_usize(
 801            worktrees
 802                .into_iter()
 803                .find(|worktree| worktree.entity_id().as_u64() as usize != worktree_id.to_usize())
 804                .expect("New worktree should have a different id")
 805                .entity_id()
 806                .as_u64() as usize,
 807        )
 808    });
 809    cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
 810
 811    let initial_history_items =
 812        open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 813    assert_eq!(
 814        initial_history_items,
 815        vec![FoundPath::new(
 816            ProjectPath {
 817                worktree_id: external_worktree_id,
 818                path: Arc::from(Path::new("")),
 819            },
 820            Some(PathBuf::from("/external-src/test/third.rs"))
 821        )],
 822        "Should show external file with its full path in the history after it was open"
 823    );
 824
 825    let updated_history_items =
 826        open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
 827    assert_eq!(
 828        updated_history_items,
 829        vec![
 830            FoundPath::new(
 831                ProjectPath {
 832                    worktree_id,
 833                    path: Arc::from(Path::new("test/second.rs")),
 834                },
 835                Some(PathBuf::from("/src/test/second.rs"))
 836            ),
 837            FoundPath::new(
 838                ProjectPath {
 839                    worktree_id: external_worktree_id,
 840                    path: Arc::from(Path::new("")),
 841                },
 842                Some(PathBuf::from("/external-src/test/third.rs"))
 843            ),
 844        ],
 845        "Should keep external file with history updates",
 846    );
 847}
 848
 849#[gpui::test]
 850async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
 851    let app_state = init_test(cx);
 852
 853    app_state
 854        .fs
 855        .as_fake()
 856        .insert_tree(
 857            "/src",
 858            json!({
 859                "test": {
 860                    "first.rs": "// First Rust file",
 861                    "second.rs": "// Second Rust file",
 862                    "third.rs": "// Third Rust file",
 863                }
 864            }),
 865        )
 866        .await;
 867
 868    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 869    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 870
 871    // generate some history to select from
 872    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
 873    cx.executor().run_until_parked();
 874    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 875    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
 876    let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 877
 878    for expected_selected_index in 0..current_history.len() {
 879        cx.dispatch_action(Toggle);
 880        let picker = active_file_picker(&workspace, cx);
 881        let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
 882        assert_eq!(
 883            selected_index, expected_selected_index,
 884            "Should select the next item in the history"
 885        );
 886    }
 887
 888    cx.dispatch_action(Toggle);
 889    let selected_index = workspace.update(cx, |workspace, cx| {
 890        workspace
 891            .active_modal::<FileFinder>(cx)
 892            .unwrap()
 893            .read(cx)
 894            .picker
 895            .read(cx)
 896            .delegate
 897            .selected_index()
 898    });
 899    assert_eq!(
 900        selected_index, 0,
 901        "Should wrap around the history and start all over"
 902    );
 903}
 904
 905#[gpui::test]
 906async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
 907    let app_state = init_test(cx);
 908
 909    app_state
 910        .fs
 911        .as_fake()
 912        .insert_tree(
 913            "/src",
 914            json!({
 915                "test": {
 916                    "first.rs": "// First Rust file",
 917                    "second.rs": "// Second Rust file",
 918                    "third.rs": "// Third Rust file",
 919                    "fourth.rs": "// Fourth Rust file",
 920                }
 921            }),
 922        )
 923        .await;
 924
 925    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
 926    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
 927    let worktree_id = cx.read(|cx| {
 928        let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
 929        assert_eq!(worktrees.len(), 1,);
 930
 931        WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
 932    });
 933
 934    // generate some history to select from
 935    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
 936    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 937    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
 938    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
 939
 940    let finder = open_file_picker(&workspace, cx);
 941    let first_query = "f";
 942    finder
 943        .update(cx, |finder, cx| {
 944            finder.delegate.update_matches(first_query.to_string(), cx)
 945        })
 946        .await;
 947    finder.update(cx, |finder, _| {
 948            let delegate = &finder.delegate;
 949            assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
 950            let history_match = delegate.matches.history.first().unwrap();
 951            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
 952            assert_eq!(history_match.0, FoundPath::new(
 953                ProjectPath {
 954                    worktree_id,
 955                    path: Arc::from(Path::new("test/first.rs")),
 956                },
 957                Some(PathBuf::from("/src/test/first.rs"))
 958            ));
 959            assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
 960            assert_eq!(delegate.matches.search.first().unwrap().0.path.as_ref(), Path::new("test/fourth.rs"));
 961        });
 962
 963    let second_query = "fsdasdsa";
 964    let finder = active_file_picker(&workspace, cx);
 965    finder
 966        .update(cx, |finder, cx| {
 967            finder.delegate.update_matches(second_query.to_string(), cx)
 968        })
 969        .await;
 970    finder.update(cx, |finder, _| {
 971        let delegate = &finder.delegate;
 972        assert!(
 973            delegate.matches.history.is_empty(),
 974            "No history entries should match {second_query}"
 975        );
 976        assert!(
 977            delegate.matches.search.is_empty(),
 978            "No search entries should match {second_query}"
 979        );
 980    });
 981
 982    let first_query_again = first_query;
 983
 984    let finder = active_file_picker(&workspace, cx);
 985    finder
 986        .update(cx, |finder, cx| {
 987            finder
 988                .delegate
 989                .update_matches(first_query_again.to_string(), cx)
 990        })
 991        .await;
 992    finder.update(cx, |finder, _| {
 993            let delegate = &finder.delegate;
 994            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");
 995            let history_match = delegate.matches.history.first().unwrap();
 996            assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
 997            assert_eq!(history_match.0, FoundPath::new(
 998                ProjectPath {
 999                    worktree_id,
1000                    path: Arc::from(Path::new("test/first.rs")),
1001                },
1002                Some(PathBuf::from("/src/test/first.rs"))
1003            ));
1004            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");
1005            assert_eq!(delegate.matches.search.first().unwrap().0.path.as_ref(), Path::new("test/fourth.rs"));
1006        });
1007}
1008
1009#[gpui::test]
1010async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1011    let app_state = init_test(cx);
1012
1013    app_state
1014        .fs
1015        .as_fake()
1016        .insert_tree(
1017            "/root",
1018            json!({
1019                "test": {
1020                    "1_qw": "// First file that matches the query",
1021                    "2_second": "// Second file",
1022                    "3_third": "// Third file",
1023                    "4_fourth": "// Fourth file",
1024                    "5_qwqwqw": "// A file with 3 more matches than the first one",
1025                    "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1026                    "7_qwqwqw": "// One more, same amount of query matches as above",
1027                }
1028            }),
1029        )
1030        .await;
1031
1032    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1033    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1034    // generate some history to select from
1035    open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1036    open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1037    open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1038    open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1039    open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1040
1041    let finder = open_file_picker(&workspace, cx);
1042    let query = "qw";
1043    finder
1044        .update(cx, |finder, cx| {
1045            finder.delegate.update_matches(query.to_string(), cx)
1046        })
1047        .await;
1048    finder.update(cx, |finder, _| {
1049        let search_matches = collect_search_matches(finder);
1050        assert_eq!(
1051            search_matches.history,
1052            vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),],
1053        );
1054        assert_eq!(
1055            search_matches.search,
1056            vec![
1057                PathBuf::from("test/5_qwqwqw"),
1058                PathBuf::from("test/7_qwqwqw"),
1059            ],
1060        );
1061    });
1062}
1063
1064#[gpui::test]
1065async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1066    let app_state = init_test(cx);
1067
1068    app_state
1069        .fs
1070        .as_fake()
1071        .insert_tree(
1072            "/src",
1073            json!({
1074                "collab_ui": {
1075                    "first.rs": "// First Rust file",
1076                    "second.rs": "// Second Rust file",
1077                    "third.rs": "// Third Rust file",
1078                    "collab_ui.rs": "// Fourth Rust file",
1079                }
1080            }),
1081        )
1082        .await;
1083
1084    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1085    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1086    // generate some history to select from
1087    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1088    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1089    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1090    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1091
1092    let finder = open_file_picker(&workspace, cx);
1093    let query = "collab_ui";
1094    cx.simulate_input(query);
1095    finder.update(cx, |finder, _| {
1096            let delegate = &finder.delegate;
1097            assert!(
1098                delegate.matches.history.is_empty(),
1099                "History items should not math query {query}, they should be matched by name only"
1100            );
1101
1102            let search_entries = delegate
1103                .matches
1104                .search
1105                .iter()
1106                .map(|path_match| path_match.0.path.to_path_buf())
1107                .collect::<Vec<_>>();
1108            assert_eq!(
1109                search_entries,
1110                vec![
1111                    PathBuf::from("collab_ui/collab_ui.rs"),
1112                    PathBuf::from("collab_ui/first.rs"),
1113                    PathBuf::from("collab_ui/third.rs"),
1114                    PathBuf::from("collab_ui/second.rs"),
1115                ],
1116                "Despite all search results having the same directory name, the most matching one should be on top"
1117            );
1118        });
1119}
1120
1121#[gpui::test]
1122async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1123    let app_state = init_test(cx);
1124
1125    app_state
1126        .fs
1127        .as_fake()
1128        .insert_tree(
1129            "/src",
1130            json!({
1131                "test": {
1132                    "first.rs": "// First Rust file",
1133                    "nonexistent.rs": "// Second Rust file",
1134                    "third.rs": "// Third Rust file",
1135                }
1136            }),
1137        )
1138        .await;
1139
1140    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1141    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1142    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1143    open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1144    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1145    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1146
1147    let picker = open_file_picker(&workspace, cx);
1148    cx.simulate_input("rs");
1149
1150    picker.update(cx, |finder, _| {
1151            let history_entries = finder.delegate
1152                .matches
1153                .history
1154                .iter()
1155                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").0.path.to_path_buf())
1156                .collect::<Vec<_>>();
1157            assert_eq!(
1158                history_entries,
1159                vec![
1160                    PathBuf::from("test/first.rs"),
1161                    PathBuf::from("test/third.rs"),
1162                ],
1163                "Should have all opened files in the history, except the ones that do not exist on disk"
1164            );
1165        });
1166}
1167
1168async fn open_close_queried_buffer(
1169    input: &str,
1170    expected_matches: usize,
1171    expected_editor_title: &str,
1172    workspace: &View<Workspace>,
1173    cx: &mut gpui::VisualTestContext,
1174) -> Vec<FoundPath> {
1175    let picker = open_file_picker(&workspace, cx);
1176    cx.simulate_input(input);
1177
1178    let history_items = picker.update(cx, |finder, _| {
1179        assert_eq!(
1180            finder.delegate.matches.len(),
1181            expected_matches,
1182            "Unexpected number of matches found for query `{input}`, matches: {:?}",
1183            finder.delegate.matches
1184        );
1185        finder.delegate.history_items.clone()
1186    });
1187
1188    cx.dispatch_action(SelectNext);
1189    cx.dispatch_action(Confirm);
1190
1191    cx.read(|cx| {
1192        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1193        let active_editor_title = active_editor.read(cx).title(cx);
1194        assert_eq!(
1195            expected_editor_title, active_editor_title,
1196            "Unexpected editor title for query `{input}`"
1197        );
1198    });
1199
1200    cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1201
1202    history_items
1203}
1204
1205fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1206    cx.update(|cx| {
1207        let state = AppState::test(cx);
1208        theme::init(theme::LoadThemes::JustBase, cx);
1209        language::init(cx);
1210        super::init(cx);
1211        editor::init(cx);
1212        workspace::init_settings(cx);
1213        Project::init_settings(cx);
1214        state
1215    })
1216}
1217
1218fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1219    PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1220        Ok::<_, std::convert::Infallible>(FileSearchQuery {
1221            raw_query: test_str.to_owned(),
1222            file_query_end: if path_like_str == test_str {
1223                None
1224            } else {
1225                Some(path_like_str.len())
1226            },
1227        })
1228    })
1229    .unwrap()
1230}
1231
1232fn build_find_picker(
1233    project: Model<Project>,
1234    cx: &mut TestAppContext,
1235) -> (
1236    View<Picker<FileFinderDelegate>>,
1237    View<Workspace>,
1238    &mut VisualTestContext,
1239) {
1240    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1241    let picker = open_file_picker(&workspace, cx);
1242    (picker, workspace, cx)
1243}
1244
1245#[track_caller]
1246fn open_file_picker(
1247    workspace: &View<Workspace>,
1248    cx: &mut VisualTestContext,
1249) -> View<Picker<FileFinderDelegate>> {
1250    cx.dispatch_action(Toggle);
1251    active_file_picker(workspace, cx)
1252}
1253
1254#[track_caller]
1255fn active_file_picker(
1256    workspace: &View<Workspace>,
1257    cx: &mut VisualTestContext,
1258) -> View<Picker<FileFinderDelegate>> {
1259    workspace.update(cx, |workspace, cx| {
1260        workspace
1261            .active_modal::<FileFinder>(cx)
1262            .unwrap()
1263            .read(cx)
1264            .picker
1265            .clone()
1266    })
1267}
1268
1269#[derive(Debug)]
1270struct SearchEntries {
1271    history: Vec<PathBuf>,
1272    search: Vec<PathBuf>,
1273}
1274
1275impl SearchEntries {
1276    #[track_caller]
1277    fn search_only(self) -> Vec<PathBuf> {
1278        assert!(
1279            self.history.is_empty(),
1280            "Should have no history matches, but got: {:?}",
1281            self.history
1282        );
1283        self.search
1284    }
1285}
1286
1287fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
1288    let matches = &picker.delegate.matches;
1289    SearchEntries {
1290        history: matches
1291            .history
1292            .iter()
1293            .map(|(history_path, path_match)| {
1294                path_match
1295                    .as_ref()
1296                    .map(|path_match| {
1297                        Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)
1298                    })
1299                    .unwrap_or_else(|| {
1300                        history_path
1301                            .absolute
1302                            .as_deref()
1303                            .unwrap_or_else(|| &history_path.project.path)
1304                            .to_path_buf()
1305                    })
1306            })
1307            .collect(),
1308        search: matches
1309            .search
1310            .iter()
1311            .map(|path_match| Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path))
1312            .collect(),
1313    }
1314}