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_results(picker),
 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_results(picker),
 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_results(picker),
 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]);
 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.clone();
 560        assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
 561        assert_eq!(matches[1].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().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().path.as_ref(), Path::new("test/fourth.rs"));
1006        });
1007}
1008
1009#[gpui::test]
1010async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1011    let app_state = init_test(cx);
1012
1013    app_state
1014        .fs
1015        .as_fake()
1016        .insert_tree(
1017            "/src",
1018            json!({
1019                "collab_ui": {
1020                    "first.rs": "// First Rust file",
1021                    "second.rs": "// Second Rust file",
1022                    "third.rs": "// Third Rust file",
1023                    "collab_ui.rs": "// Fourth Rust file",
1024                }
1025            }),
1026        )
1027        .await;
1028
1029    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1030    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1031    // generate some history to select from
1032    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1033    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1034    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1035    open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1036
1037    let finder = open_file_picker(&workspace, cx);
1038    let query = "collab_ui";
1039    cx.simulate_input(query);
1040    finder.update(cx, |finder, _| {
1041            let delegate = &finder.delegate;
1042            assert!(
1043                delegate.matches.history.is_empty(),
1044                "History items should not math query {query}, they should be matched by name only"
1045            );
1046
1047            let search_entries = delegate
1048                .matches
1049                .search
1050                .iter()
1051                .map(|path_match| path_match.path.to_path_buf())
1052                .collect::<Vec<_>>();
1053            assert_eq!(
1054                search_entries,
1055                vec![
1056                    PathBuf::from("collab_ui/collab_ui.rs"),
1057                    PathBuf::from("collab_ui/third.rs"),
1058                    PathBuf::from("collab_ui/first.rs"),
1059                    PathBuf::from("collab_ui/second.rs"),
1060                ],
1061                "Despite all search results having the same directory name, the most matching one should be on top"
1062            );
1063        });
1064}
1065
1066#[gpui::test]
1067async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1068    let app_state = init_test(cx);
1069
1070    app_state
1071        .fs
1072        .as_fake()
1073        .insert_tree(
1074            "/src",
1075            json!({
1076                "test": {
1077                    "first.rs": "// First Rust file",
1078                    "nonexistent.rs": "// Second Rust file",
1079                    "third.rs": "// Third Rust file",
1080                }
1081            }),
1082        )
1083        .await;
1084
1085    let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1086    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1087    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1088    open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1089    open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1090    open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1091
1092    let picker = open_file_picker(&workspace, cx);
1093    cx.simulate_input("rs");
1094
1095    picker.update(cx, |finder, _| {
1096            let history_entries = finder.delegate
1097                .matches
1098                .history
1099                .iter()
1100                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1101                .collect::<Vec<_>>();
1102            assert_eq!(
1103                history_entries,
1104                vec![
1105                    PathBuf::from("test/first.rs"),
1106                    PathBuf::from("test/third.rs"),
1107                ],
1108                "Should have all opened files in the history, except the ones that do not exist on disk"
1109            );
1110        });
1111}
1112
1113async fn open_close_queried_buffer(
1114    input: &str,
1115    expected_matches: usize,
1116    expected_editor_title: &str,
1117    workspace: &View<Workspace>,
1118    cx: &mut gpui::VisualTestContext,
1119) -> Vec<FoundPath> {
1120    let picker = open_file_picker(&workspace, cx);
1121    cx.simulate_input(input);
1122
1123    let history_items = picker.update(cx, |finder, _| {
1124        assert_eq!(
1125            finder.delegate.matches.len(),
1126            expected_matches,
1127            "Unexpected number of matches found for query {input}"
1128        );
1129        finder.delegate.history_items.clone()
1130    });
1131
1132    cx.dispatch_action(SelectNext);
1133    cx.dispatch_action(Confirm);
1134
1135    cx.read(|cx| {
1136        let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1137        let active_editor_title = active_editor.read(cx).title(cx);
1138        assert_eq!(
1139            expected_editor_title, active_editor_title,
1140            "Unexpected editor title for query {input}"
1141        );
1142    });
1143
1144    cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1145
1146    history_items
1147}
1148
1149fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1150    cx.update(|cx| {
1151        let state = AppState::test(cx);
1152        theme::init(theme::LoadThemes::JustBase, cx);
1153        language::init(cx);
1154        super::init(cx);
1155        editor::init(cx);
1156        workspace::init_settings(cx);
1157        Project::init_settings(cx);
1158        state
1159    })
1160}
1161
1162fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1163    PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1164        Ok::<_, std::convert::Infallible>(FileSearchQuery {
1165            raw_query: test_str.to_owned(),
1166            file_query_end: if path_like_str == test_str {
1167                None
1168            } else {
1169                Some(path_like_str.len())
1170            },
1171        })
1172    })
1173    .unwrap()
1174}
1175
1176fn build_find_picker(
1177    project: Model<Project>,
1178    cx: &mut TestAppContext,
1179) -> (
1180    View<Picker<FileFinderDelegate>>,
1181    View<Workspace>,
1182    &mut VisualTestContext,
1183) {
1184    let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1185    let picker = open_file_picker(&workspace, cx);
1186    (picker, workspace, cx)
1187}
1188
1189#[track_caller]
1190fn open_file_picker(
1191    workspace: &View<Workspace>,
1192    cx: &mut VisualTestContext,
1193) -> View<Picker<FileFinderDelegate>> {
1194    cx.dispatch_action(Toggle);
1195    active_file_picker(workspace, cx)
1196}
1197
1198#[track_caller]
1199fn active_file_picker(
1200    workspace: &View<Workspace>,
1201    cx: &mut VisualTestContext,
1202) -> View<Picker<FileFinderDelegate>> {
1203    workspace.update(cx, |workspace, cx| {
1204        workspace
1205            .active_modal::<FileFinder>(cx)
1206            .unwrap()
1207            .read(cx)
1208            .picker
1209            .clone()
1210    })
1211}
1212
1213fn collect_search_results(picker: &Picker<FileFinderDelegate>) -> Vec<PathBuf> {
1214    let matches = &picker.delegate.matches;
1215    assert!(
1216        matches.history.is_empty(),
1217        "Should have no history matches, but got: {:?}",
1218        matches.history
1219    );
1220    let mut results = matches
1221        .search
1222        .iter()
1223        .map(|path_match| Path::new(path_match.path_prefix.as_ref()).join(&path_match.path))
1224        .collect::<Vec<_>>();
1225    results.sort();
1226    results
1227}