project_panel_tests.rs

   1use super::*;
   2use collections::HashSet;
   3use editor::MultiBufferOffset;
   4use gpui::{Empty, Entity, TestAppContext, VisualTestContext};
   5use menu::Cancel;
   6use pretty_assertions::assert_eq;
   7use project::FakeFs;
   8use serde_json::json;
   9use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
  10use std::path::{Path, PathBuf};
  11use util::{path, paths::PathStyle, rel_path::rel_path};
  12use workspace::{
  13    AppState, ItemHandle, MultiWorkspace, Pane, Workspace,
  14    item::{Item, ProjectItem},
  15    register_project_item,
  16};
  17
  18#[gpui::test]
  19async fn test_visible_list(cx: &mut gpui::TestAppContext) {
  20    init_test(cx);
  21
  22    let fs = FakeFs::new(cx.executor());
  23    fs.insert_tree(
  24        "/root1",
  25        json!({
  26            ".dockerignore": "",
  27            ".git": {
  28                "HEAD": "",
  29            },
  30            "a": {
  31                "0": { "q": "", "r": "", "s": "" },
  32                "1": { "t": "", "u": "" },
  33                "2": { "v": "", "w": "", "x": "", "y": "" },
  34            },
  35            "b": {
  36                "3": { "Q": "" },
  37                "4": { "R": "", "S": "", "T": "", "U": "" },
  38            },
  39            "C": {
  40                "5": {},
  41                "6": { "V": "", "W": "" },
  42                "7": { "X": "" },
  43                "8": { "Y": {}, "Z": "" }
  44            }
  45        }),
  46    )
  47    .await;
  48    fs.insert_tree(
  49        "/root2",
  50        json!({
  51            "d": {
  52                "9": ""
  53            },
  54            "e": {}
  55        }),
  56    )
  57    .await;
  58
  59    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
  60    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
  61    let workspace = window
  62        .read_with(cx, |mw, _| mw.workspace().clone())
  63        .unwrap();
  64    let cx = &mut VisualTestContext::from_window(window.into(), cx);
  65    let panel = workspace.update_in(cx, ProjectPanel::new);
  66    cx.run_until_parked();
  67    assert_eq!(
  68        visible_entries_as_strings(&panel, 0..50, cx),
  69        &[
  70            "v root1",
  71            "    > .git",
  72            "    > a",
  73            "    > b",
  74            "    > C",
  75            "      .dockerignore",
  76            "v root2",
  77            "    > d",
  78            "    > e",
  79        ]
  80    );
  81
  82    toggle_expand_dir(&panel, "root1/b", cx);
  83    assert_eq!(
  84        visible_entries_as_strings(&panel, 0..50, cx),
  85        &[
  86            "v root1",
  87            "    > .git",
  88            "    > a",
  89            "    v b  <== selected",
  90            "        > 3",
  91            "        > 4",
  92            "    > C",
  93            "      .dockerignore",
  94            "v root2",
  95            "    > d",
  96            "    > e",
  97        ]
  98    );
  99
 100    assert_eq!(
 101        visible_entries_as_strings(&panel, 6..9, cx),
 102        &[
 103            //
 104            "    > C",
 105            "      .dockerignore",
 106            "v root2",
 107        ]
 108    );
 109}
 110
 111#[gpui::test]
 112async fn test_opening_file(cx: &mut gpui::TestAppContext) {
 113    init_test_with_editor(cx);
 114
 115    let fs = FakeFs::new(cx.executor());
 116    fs.insert_tree(
 117        path!("/src"),
 118        json!({
 119            "test": {
 120                "first.rs": "// First Rust file",
 121                "second.rs": "// Second Rust file",
 122                "third.rs": "// Third Rust file",
 123            }
 124        }),
 125    )
 126    .await;
 127
 128    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
 129    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 130    let workspace = window
 131        .read_with(cx, |mw, _| mw.workspace().clone())
 132        .unwrap();
 133    let cx = &mut VisualTestContext::from_window(window.into(), cx);
 134    let panel = workspace.update_in(cx, ProjectPanel::new);
 135    cx.run_until_parked();
 136
 137    toggle_expand_dir(&panel, "src/test", cx);
 138    select_path(&panel, "src/test/first.rs", cx);
 139    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 140    cx.executor().run_until_parked();
 141    assert_eq!(
 142        visible_entries_as_strings(&panel, 0..10, cx),
 143        &[
 144            "v src",
 145            "    v test",
 146            "          first.rs  <== selected  <== marked",
 147            "          second.rs",
 148            "          third.rs"
 149        ]
 150    );
 151    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
 152
 153    select_path(&panel, "src/test/second.rs", cx);
 154    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 155    cx.executor().run_until_parked();
 156    assert_eq!(
 157        visible_entries_as_strings(&panel, 0..10, cx),
 158        &[
 159            "v src",
 160            "    v test",
 161            "          first.rs",
 162            "          second.rs  <== selected  <== marked",
 163            "          third.rs"
 164        ]
 165    );
 166    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
 167}
 168
 169#[gpui::test]
 170async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
 171    init_test(cx);
 172    cx.update(|cx| {
 173        cx.update_global::<SettingsStore, _>(|store, cx| {
 174            store.update_user_settings(cx, |settings| {
 175                settings.project.worktree.file_scan_exclusions =
 176                    Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
 177            });
 178        });
 179    });
 180
 181    let fs = FakeFs::new(cx.background_executor.clone());
 182    fs.insert_tree(
 183        "/root1",
 184        json!({
 185            ".dockerignore": "",
 186            ".git": {
 187                "HEAD": "",
 188            },
 189            "a": {
 190                "0": { "q": "", "r": "", "s": "" },
 191                "1": { "t": "", "u": "" },
 192                "2": { "v": "", "w": "", "x": "", "y": "" },
 193            },
 194            "b": {
 195                "3": { "Q": "" },
 196                "4": { "R": "", "S": "", "T": "", "U": "" },
 197            },
 198            "C": {
 199                "5": {},
 200                "6": { "V": "", "W": "" },
 201                "7": { "X": "" },
 202                "8": { "Y": {}, "Z": "" }
 203            }
 204        }),
 205    )
 206    .await;
 207    fs.insert_tree(
 208        "/root2",
 209        json!({
 210            "d": {
 211                "4": ""
 212            },
 213            "e": {}
 214        }),
 215    )
 216    .await;
 217
 218    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 219    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 220    let workspace = window
 221        .read_with(cx, |mw, _| mw.workspace().clone())
 222        .unwrap();
 223    let cx = &mut VisualTestContext::from_window(window.into(), cx);
 224    let panel = workspace.update_in(cx, ProjectPanel::new);
 225    cx.run_until_parked();
 226    assert_eq!(
 227        visible_entries_as_strings(&panel, 0..50, cx),
 228        &[
 229            "v root1",
 230            "    > a",
 231            "    > b",
 232            "    > C",
 233            "      .dockerignore",
 234            "v root2",
 235            "    > d",
 236            "    > e",
 237        ]
 238    );
 239
 240    toggle_expand_dir(&panel, "root1/b", cx);
 241    assert_eq!(
 242        visible_entries_as_strings(&panel, 0..50, cx),
 243        &[
 244            "v root1",
 245            "    > a",
 246            "    v b  <== selected",
 247            "        > 3",
 248            "    > C",
 249            "      .dockerignore",
 250            "v root2",
 251            "    > d",
 252            "    > e",
 253        ]
 254    );
 255
 256    toggle_expand_dir(&panel, "root2/d", cx);
 257    assert_eq!(
 258        visible_entries_as_strings(&panel, 0..50, cx),
 259        &[
 260            "v root1",
 261            "    > a",
 262            "    v b",
 263            "        > 3",
 264            "    > C",
 265            "      .dockerignore",
 266            "v root2",
 267            "    v d  <== selected",
 268            "    > e",
 269        ]
 270    );
 271
 272    toggle_expand_dir(&panel, "root2/e", cx);
 273    assert_eq!(
 274        visible_entries_as_strings(&panel, 0..50, cx),
 275        &[
 276            "v root1",
 277            "    > a",
 278            "    v b",
 279            "        > 3",
 280            "    > C",
 281            "      .dockerignore",
 282            "v root2",
 283            "    v d",
 284            "    v e  <== selected",
 285        ]
 286    );
 287}
 288
 289#[gpui::test]
 290async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
 291    init_test(cx);
 292
 293    let fs = FakeFs::new(cx.executor());
 294    fs.insert_tree(
 295        path!("/root1"),
 296        json!({
 297            "dir_1": {
 298                "nested_dir_1": {
 299                    "nested_dir_2": {
 300                        "nested_dir_3": {
 301                            "file_a.java": "// File contents",
 302                            "file_b.java": "// File contents",
 303                            "file_c.java": "// File contents",
 304                            "nested_dir_4": {
 305                                "nested_dir_5": {
 306                                    "file_d.java": "// File contents",
 307                                }
 308                            }
 309                        }
 310                    }
 311                }
 312            }
 313        }),
 314    )
 315    .await;
 316    fs.insert_tree(
 317        path!("/root2"),
 318        json!({
 319            "dir_2": {
 320                "file_1.java": "// File contents",
 321            }
 322        }),
 323    )
 324    .await;
 325
 326    // Test 1: Multiple worktrees with auto_fold_dirs = true
 327    let project = Project::test(
 328        fs.clone(),
 329        [path!("/root1").as_ref(), path!("/root2").as_ref()],
 330        cx,
 331    )
 332    .await;
 333    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 334    let workspace = window
 335        .read_with(cx, |mw, _| mw.workspace().clone())
 336        .unwrap();
 337    let cx = &mut VisualTestContext::from_window(window.into(), cx);
 338    cx.update(|_, cx| {
 339        let settings = *ProjectPanelSettings::get_global(cx);
 340        ProjectPanelSettings::override_global(
 341            ProjectPanelSettings {
 342                auto_fold_dirs: true,
 343                sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
 344                ..settings
 345            },
 346            cx,
 347        );
 348    });
 349    let panel = workspace.update_in(cx, ProjectPanel::new);
 350    cx.run_until_parked();
 351    assert_eq!(
 352        visible_entries_as_strings(&panel, 0..10, cx),
 353        &[
 354            "v root1",
 355            "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 356            "v root2",
 357            "    > dir_2",
 358        ]
 359    );
 360
 361    toggle_expand_dir(
 362        &panel,
 363        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 364        cx,
 365    );
 366    assert_eq!(
 367        visible_entries_as_strings(&panel, 0..10, cx),
 368        &[
 369            "v root1",
 370            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
 371            "        > nested_dir_4/nested_dir_5",
 372            "          file_a.java",
 373            "          file_b.java",
 374            "          file_c.java",
 375            "v root2",
 376            "    > dir_2",
 377        ]
 378    );
 379
 380    toggle_expand_dir(
 381        &panel,
 382        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
 383        cx,
 384    );
 385    assert_eq!(
 386        visible_entries_as_strings(&panel, 0..10, cx),
 387        &[
 388            "v root1",
 389            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 390            "        v nested_dir_4/nested_dir_5  <== selected",
 391            "              file_d.java",
 392            "          file_a.java",
 393            "          file_b.java",
 394            "          file_c.java",
 395            "v root2",
 396            "    > dir_2",
 397        ]
 398    );
 399    toggle_expand_dir(&panel, "root2/dir_2", cx);
 400    assert_eq!(
 401        visible_entries_as_strings(&panel, 0..10, cx),
 402        &[
 403            "v root1",
 404            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 405            "        v nested_dir_4/nested_dir_5",
 406            "              file_d.java",
 407            "          file_a.java",
 408            "          file_b.java",
 409            "          file_c.java",
 410            "v root2",
 411            "    v dir_2  <== selected",
 412            "          file_1.java",
 413        ]
 414    );
 415
 416    // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
 417    {
 418        let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
 419        let window =
 420            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 421        let workspace = window
 422            .read_with(cx, |mw, _| mw.workspace().clone())
 423            .unwrap();
 424        let cx = &mut VisualTestContext::from_window(window.into(), cx);
 425        cx.update(|_, cx| {
 426            let settings = *ProjectPanelSettings::get_global(cx);
 427            ProjectPanelSettings::override_global(
 428                ProjectPanelSettings {
 429                    auto_fold_dirs: true,
 430                    hide_root: true,
 431                    ..settings
 432                },
 433                cx,
 434            );
 435        });
 436        let panel = workspace.update_in(cx, ProjectPanel::new);
 437        cx.run_until_parked();
 438        assert_eq!(
 439            visible_entries_as_strings(&panel, 0..10, cx),
 440            &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
 441            "Single worktree with hide_root=true should hide root and show auto-folded paths"
 442        );
 443
 444        toggle_expand_dir(
 445            &panel,
 446            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 447            cx,
 448        );
 449        assert_eq!(
 450            visible_entries_as_strings(&panel, 0..10, cx),
 451            &[
 452                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
 453                "    > nested_dir_4/nested_dir_5",
 454                "      file_a.java",
 455                "      file_b.java",
 456                "      file_c.java",
 457            ],
 458            "Expanded auto-folded path with hidden root should show contents without root prefix"
 459        );
 460
 461        toggle_expand_dir(
 462            &panel,
 463            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
 464            cx,
 465        );
 466        assert_eq!(
 467            visible_entries_as_strings(&panel, 0..10, cx),
 468            &[
 469                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 470                "    v nested_dir_4/nested_dir_5  <== selected",
 471                "          file_d.java",
 472                "      file_a.java",
 473                "      file_b.java",
 474                "      file_c.java",
 475            ],
 476            "Nested expansion with hidden root should maintain proper indentation"
 477        );
 478    }
 479}
 480
 481#[gpui::test(iterations = 30)]
 482async fn test_editing_files(cx: &mut gpui::TestAppContext) {
 483    init_test(cx);
 484
 485    let fs = FakeFs::new(cx.executor());
 486    fs.insert_tree(
 487        "/root1",
 488        json!({
 489            ".dockerignore": "",
 490            ".git": {
 491                "HEAD": "",
 492            },
 493            "a": {
 494                "0": { "q": "", "r": "", "s": "" },
 495                "1": { "t": "", "u": "" },
 496                "2": { "v": "", "w": "", "x": "", "y": "" },
 497            },
 498            "b": {
 499                "3": { "Q": "" },
 500                "4": { "R": "", "S": "", "T": "", "U": "" },
 501            },
 502            "C": {
 503                "5": {},
 504                "6": { "V": "", "W": "" },
 505                "7": { "X": "" },
 506                "8": { "Y": {}, "Z": "" }
 507            }
 508        }),
 509    )
 510    .await;
 511    fs.insert_tree(
 512        "/root2",
 513        json!({
 514            "d": {
 515                "9": ""
 516            },
 517            "e": {}
 518        }),
 519    )
 520    .await;
 521
 522    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 523    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 524    let workspace = window
 525        .read_with(cx, |mw, _| mw.workspace().clone())
 526        .unwrap();
 527    let cx = &mut VisualTestContext::from_window(window.into(), cx);
 528    let panel = workspace.update_in(cx, |workspace, window, cx| {
 529        let panel = ProjectPanel::new(workspace, window, cx);
 530        workspace.add_panel(panel.clone(), window, cx);
 531        panel
 532    });
 533    cx.run_until_parked();
 534
 535    select_path(&panel, "root1", cx);
 536    assert_eq!(
 537        visible_entries_as_strings(&panel, 0..10, cx),
 538        &[
 539            "v root1  <== selected",
 540            "    > .git",
 541            "    > a",
 542            "    > b",
 543            "    > C",
 544            "      .dockerignore",
 545            "v root2",
 546            "    > d",
 547            "    > e",
 548        ]
 549    );
 550
 551    // Add a file with the root folder selected. The filename editor is placed
 552    // before the first file in the root folder.
 553    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 554    cx.run_until_parked();
 555    panel.update_in(cx, |panel, window, cx| {
 556        assert!(panel.filename_editor.read(cx).is_focused(window));
 557    });
 558    assert_eq!(
 559        visible_entries_as_strings(&panel, 0..10, cx),
 560        &[
 561            "v root1",
 562            "    > .git",
 563            "    > a",
 564            "    > b",
 565            "    > C",
 566            "      [EDITOR: '']  <== selected",
 567            "      .dockerignore",
 568            "v root2",
 569            "    > d",
 570            "    > e",
 571        ]
 572    );
 573
 574    let confirm = panel.update_in(cx, |panel, window, cx| {
 575        panel.filename_editor.update(cx, |editor, cx| {
 576            editor.set_text("the-new-filename", window, cx)
 577        });
 578        panel.confirm_edit(true, window, cx).unwrap()
 579    });
 580    assert_eq!(
 581        visible_entries_as_strings(&panel, 0..10, cx),
 582        &[
 583            "v root1",
 584            "    > .git",
 585            "    > a",
 586            "    > b",
 587            "    > C",
 588            "      [PROCESSING: 'the-new-filename']  <== selected",
 589            "      .dockerignore",
 590            "v root2",
 591            "    > d",
 592            "    > e",
 593        ]
 594    );
 595
 596    confirm.await.unwrap();
 597    cx.run_until_parked();
 598    assert_eq!(
 599        visible_entries_as_strings(&panel, 0..10, cx),
 600        &[
 601            "v root1",
 602            "    > .git",
 603            "    > a",
 604            "    > b",
 605            "    > C",
 606            "      .dockerignore",
 607            "      the-new-filename  <== selected  <== marked",
 608            "v root2",
 609            "    > d",
 610            "    > e",
 611        ]
 612    );
 613
 614    select_path(&panel, "root1/b", cx);
 615    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 616    cx.run_until_parked();
 617    assert_eq!(
 618        visible_entries_as_strings(&panel, 0..10, cx),
 619        &[
 620            "v root1",
 621            "    > .git",
 622            "    > a",
 623            "    v b",
 624            "        > 3",
 625            "        > 4",
 626            "          [EDITOR: '']  <== selected",
 627            "    > C",
 628            "      .dockerignore",
 629            "      the-new-filename",
 630        ]
 631    );
 632
 633    panel
 634        .update_in(cx, |panel, window, cx| {
 635            panel.filename_editor.update(cx, |editor, cx| {
 636                editor.set_text("another-filename.txt", window, cx)
 637            });
 638            panel.confirm_edit(true, window, cx).unwrap()
 639        })
 640        .await
 641        .unwrap();
 642    cx.run_until_parked();
 643    assert_eq!(
 644        visible_entries_as_strings(&panel, 0..10, cx),
 645        &[
 646            "v root1",
 647            "    > .git",
 648            "    > a",
 649            "    v b",
 650            "        > 3",
 651            "        > 4",
 652            "          another-filename.txt  <== selected  <== marked",
 653            "    > C",
 654            "      .dockerignore",
 655            "      the-new-filename",
 656        ]
 657    );
 658
 659    select_path(&panel, "root1/b/another-filename.txt", cx);
 660    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 661    assert_eq!(
 662        visible_entries_as_strings(&panel, 0..10, cx),
 663        &[
 664            "v root1",
 665            "    > .git",
 666            "    > a",
 667            "    v b",
 668            "        > 3",
 669            "        > 4",
 670            "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
 671            "    > C",
 672            "      .dockerignore",
 673            "      the-new-filename",
 674        ]
 675    );
 676
 677    let confirm = panel.update_in(cx, |panel, window, cx| {
 678        panel.filename_editor.update(cx, |editor, cx| {
 679            let file_name_selections = editor
 680                .selections
 681                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
 682            assert_eq!(
 683                file_name_selections.len(),
 684                1,
 685                "File editing should have a single selection, but got: {file_name_selections:?}"
 686            );
 687            let file_name_selection = &file_name_selections[0];
 688            assert_eq!(
 689                file_name_selection.start,
 690                MultiBufferOffset(0),
 691                "Should select the file name from the start"
 692            );
 693            assert_eq!(
 694                file_name_selection.end,
 695                MultiBufferOffset("another-filename".len()),
 696                "Should not select file extension"
 697            );
 698
 699            editor.set_text("a-different-filename.tar.gz", window, cx)
 700        });
 701        panel.confirm_edit(true, window, cx).unwrap()
 702    });
 703    assert_eq!(
 704        visible_entries_as_strings(&panel, 0..10, cx),
 705        &[
 706            "v root1",
 707            "    > .git",
 708            "    > a",
 709            "    v b",
 710            "        > 3",
 711            "        > 4",
 712            "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
 713            "    > C",
 714            "      .dockerignore",
 715            "      the-new-filename",
 716        ]
 717    );
 718
 719    confirm.await.unwrap();
 720    cx.run_until_parked();
 721    assert_eq!(
 722        visible_entries_as_strings(&panel, 0..10, cx),
 723        &[
 724            "v root1",
 725            "    > .git",
 726            "    > a",
 727            "    v b",
 728            "        > 3",
 729            "        > 4",
 730            "          a-different-filename.tar.gz  <== selected",
 731            "    > C",
 732            "      .dockerignore",
 733            "      the-new-filename",
 734        ]
 735    );
 736
 737    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 738    assert_eq!(
 739        visible_entries_as_strings(&panel, 0..10, cx),
 740        &[
 741            "v root1",
 742            "    > .git",
 743            "    > a",
 744            "    v b",
 745            "        > 3",
 746            "        > 4",
 747            "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
 748            "    > C",
 749            "      .dockerignore",
 750            "      the-new-filename",
 751        ]
 752    );
 753
 754    panel.update_in(cx, |panel, window, cx| {
 755            panel.filename_editor.update(cx, |editor, cx| {
 756                let file_name_selections = editor.selections.all::<MultiBufferOffset>(&editor.display_snapshot(cx));
 757                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
 758                let file_name_selection = &file_name_selections[0];
 759                assert_eq!(file_name_selection.start, MultiBufferOffset(0), "Should select the file name from the start");
 760                assert_eq!(file_name_selection.end, MultiBufferOffset("a-different-filename.tar".len()), "Should not select file extension, but still may select anything up to the last dot..");
 761
 762            });
 763            panel.cancel(&menu::Cancel, window, cx)
 764        });
 765    cx.run_until_parked();
 766    panel.update_in(cx, |panel, window, cx| {
 767        panel.new_directory(&NewDirectory, window, cx)
 768    });
 769    cx.run_until_parked();
 770    assert_eq!(
 771        visible_entries_as_strings(&panel, 0..10, cx),
 772        &[
 773            "v root1",
 774            "    > .git",
 775            "    > a",
 776            "    v b",
 777            "        > [EDITOR: '']  <== selected",
 778            "        > 3",
 779            "        > 4",
 780            "          a-different-filename.tar.gz",
 781            "    > C",
 782            "      .dockerignore",
 783        ]
 784    );
 785
 786    let confirm = panel.update_in(cx, |panel, window, cx| {
 787        panel
 788            .filename_editor
 789            .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
 790        panel.confirm_edit(true, window, cx).unwrap()
 791    });
 792    panel.update_in(cx, |panel, window, cx| {
 793        panel.select_next(&Default::default(), window, cx)
 794    });
 795    assert_eq!(
 796        visible_entries_as_strings(&panel, 0..10, cx),
 797        &[
 798            "v root1",
 799            "    > .git",
 800            "    > a",
 801            "    v b",
 802            "        > [PROCESSING: 'new-dir']",
 803            "        > 3  <== selected",
 804            "        > 4",
 805            "          a-different-filename.tar.gz",
 806            "    > C",
 807            "      .dockerignore",
 808        ]
 809    );
 810
 811    confirm.await.unwrap();
 812    cx.run_until_parked();
 813    assert_eq!(
 814        visible_entries_as_strings(&panel, 0..10, cx),
 815        &[
 816            "v root1",
 817            "    > .git",
 818            "    > a",
 819            "    v b",
 820            "        > 3  <== selected",
 821            "        > 4",
 822            "        > new-dir",
 823            "          a-different-filename.tar.gz",
 824            "    > C",
 825            "      .dockerignore",
 826        ]
 827    );
 828
 829    panel.update_in(cx, |panel, window, cx| {
 830        panel.rename(&Default::default(), window, cx)
 831    });
 832    cx.run_until_parked();
 833    assert_eq!(
 834        visible_entries_as_strings(&panel, 0..10, cx),
 835        &[
 836            "v root1",
 837            "    > .git",
 838            "    > a",
 839            "    v b",
 840            "        > [EDITOR: '3']  <== selected",
 841            "        > 4",
 842            "        > new-dir",
 843            "          a-different-filename.tar.gz",
 844            "    > C",
 845            "      .dockerignore",
 846        ]
 847    );
 848
 849    // Dismiss the rename editor when it loses focus.
 850    workspace.update_in(cx, |_, window, _| window.blur());
 851    assert_eq!(
 852        visible_entries_as_strings(&panel, 0..10, cx),
 853        &[
 854            "v root1",
 855            "    > .git",
 856            "    > a",
 857            "    v b",
 858            "        > 3  <== selected",
 859            "        > 4",
 860            "        > new-dir",
 861            "          a-different-filename.tar.gz",
 862            "    > C",
 863            "      .dockerignore",
 864        ]
 865    );
 866
 867    // Test empty filename and filename with only whitespace
 868    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 869    cx.run_until_parked();
 870    assert_eq!(
 871        visible_entries_as_strings(&panel, 0..10, cx),
 872        &[
 873            "v root1",
 874            "    > .git",
 875            "    > a",
 876            "    v b",
 877            "        v 3",
 878            "              [EDITOR: '']  <== selected",
 879            "              Q",
 880            "        > 4",
 881            "        > new-dir",
 882            "          a-different-filename.tar.gz",
 883        ]
 884    );
 885    panel.update_in(cx, |panel, window, cx| {
 886        panel.filename_editor.update(cx, |editor, cx| {
 887            editor.set_text("", window, cx);
 888        });
 889        assert!(panel.confirm_edit(true, window, cx).is_none());
 890        panel.filename_editor.update(cx, |editor, cx| {
 891            editor.set_text("   ", window, cx);
 892        });
 893        assert!(panel.confirm_edit(true, window, cx).is_none());
 894        panel.cancel(&menu::Cancel, window, cx);
 895    });
 896    cx.run_until_parked();
 897    assert_eq!(
 898        visible_entries_as_strings(&panel, 0..10, cx),
 899        &[
 900            "v root1",
 901            "    > .git",
 902            "    > a",
 903            "    v b",
 904            "        v 3  <== selected",
 905            "              Q",
 906            "        > 4",
 907            "        > new-dir",
 908            "          a-different-filename.tar.gz",
 909            "    > C",
 910        ]
 911    );
 912}
 913
 914#[gpui::test(iterations = 10)]
 915async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
 916    init_test(cx);
 917
 918    let fs = FakeFs::new(cx.executor());
 919    fs.insert_tree(
 920        "/root1",
 921        json!({
 922            ".dockerignore": "",
 923            ".git": {
 924                "HEAD": "",
 925            },
 926            "a": {
 927                "0": { "q": "", "r": "", "s": "" },
 928                "1": { "t": "", "u": "" },
 929                "2": { "v": "", "w": "", "x": "", "y": "" },
 930            },
 931            "b": {
 932                "3": { "Q": "" },
 933                "4": { "R": "", "S": "", "T": "", "U": "" },
 934            },
 935            "C": {
 936                "5": {},
 937                "6": { "V": "", "W": "" },
 938                "7": { "X": "" },
 939                "8": { "Y": {}, "Z": "" }
 940            }
 941        }),
 942    )
 943    .await;
 944    fs.insert_tree(
 945        "/root2",
 946        json!({
 947            "d": {
 948                "9": ""
 949            },
 950            "e": {}
 951        }),
 952    )
 953    .await;
 954
 955    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 956    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 957    let workspace = window
 958        .read_with(cx, |mw, _| mw.workspace().clone())
 959        .unwrap();
 960    let cx = &mut VisualTestContext::from_window(window.into(), cx);
 961    let panel = workspace.update_in(cx, |workspace, window, cx| {
 962        let panel = ProjectPanel::new(workspace, window, cx);
 963        workspace.add_panel(panel.clone(), window, cx);
 964        panel
 965    });
 966    cx.run_until_parked();
 967
 968    select_path(&panel, "root1", cx);
 969    assert_eq!(
 970        visible_entries_as_strings(&panel, 0..10, cx),
 971        &[
 972            "v root1  <== selected",
 973            "    > .git",
 974            "    > a",
 975            "    > b",
 976            "    > C",
 977            "      .dockerignore",
 978            "v root2",
 979            "    > d",
 980            "    > e",
 981        ]
 982    );
 983
 984    // Add a file with the root folder selected. The filename editor is placed
 985    // before the first file in the root folder.
 986    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 987    cx.run_until_parked();
 988    panel.update_in(cx, |panel, window, cx| {
 989        assert!(panel.filename_editor.read(cx).is_focused(window));
 990    });
 991    cx.run_until_parked();
 992    assert_eq!(
 993        visible_entries_as_strings(&panel, 0..10, cx),
 994        &[
 995            "v root1",
 996            "    > .git",
 997            "    > a",
 998            "    > b",
 999            "    > C",
1000            "      [EDITOR: '']  <== selected",
1001            "      .dockerignore",
1002            "v root2",
1003            "    > d",
1004            "    > e",
1005        ]
1006    );
1007
1008    let confirm = panel.update_in(cx, |panel, window, cx| {
1009        panel.filename_editor.update(cx, |editor, cx| {
1010            editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
1011        });
1012        panel.confirm_edit(true, window, cx).unwrap()
1013    });
1014
1015    assert_eq!(
1016        visible_entries_as_strings(&panel, 0..10, cx),
1017        &[
1018            "v root1",
1019            "    > .git",
1020            "    > a",
1021            "    > b",
1022            "    > C",
1023            "      [PROCESSING: 'bdir1/dir2/the-new-filename']  <== selected",
1024            "      .dockerignore",
1025            "v root2",
1026            "    > d",
1027            "    > e",
1028        ]
1029    );
1030
1031    confirm.await.unwrap();
1032    cx.run_until_parked();
1033    assert_eq!(
1034        visible_entries_as_strings(&panel, 0..13, cx),
1035        &[
1036            "v root1",
1037            "    > .git",
1038            "    > a",
1039            "    > b",
1040            "    v bdir1",
1041            "        v dir2",
1042            "              the-new-filename  <== selected  <== marked",
1043            "    > C",
1044            "      .dockerignore",
1045            "v root2",
1046            "    > d",
1047            "    > e",
1048        ]
1049    );
1050}
1051
1052#[gpui::test]
1053async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
1054    init_test(cx);
1055
1056    let fs = FakeFs::new(cx.executor());
1057    fs.insert_tree(
1058        path!("/root1"),
1059        json!({
1060            ".dockerignore": "",
1061            ".git": {
1062                "HEAD": "",
1063            },
1064        }),
1065    )
1066    .await;
1067
1068    let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
1069    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1070    let workspace = window
1071        .read_with(cx, |mw, _| mw.workspace().clone())
1072        .unwrap();
1073    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1074    let panel = workspace.update_in(cx, |workspace, window, cx| {
1075        let panel = ProjectPanel::new(workspace, window, cx);
1076        workspace.add_panel(panel.clone(), window, cx);
1077        panel
1078    });
1079    cx.run_until_parked();
1080
1081    select_path(&panel, "root1", cx);
1082    assert_eq!(
1083        visible_entries_as_strings(&panel, 0..10, cx),
1084        &["v root1  <== selected", "    > .git", "      .dockerignore",]
1085    );
1086
1087    // Add a file with the root folder selected. The filename editor is placed
1088    // before the first file in the root folder.
1089    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1090    cx.run_until_parked();
1091    panel.update_in(cx, |panel, window, cx| {
1092        assert!(panel.filename_editor.read(cx).is_focused(window));
1093    });
1094    assert_eq!(
1095        visible_entries_as_strings(&panel, 0..10, cx),
1096        &[
1097            "v root1",
1098            "    > .git",
1099            "      [EDITOR: '']  <== selected",
1100            "      .dockerignore",
1101        ]
1102    );
1103
1104    let confirm = panel.update_in(cx, |panel, window, cx| {
1105        // If we want to create a subdirectory, there should be no prefix slash.
1106        panel
1107            .filename_editor
1108            .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
1109        panel.confirm_edit(true, window, cx).unwrap()
1110    });
1111
1112    assert_eq!(
1113        visible_entries_as_strings(&panel, 0..10, cx),
1114        &[
1115            "v root1",
1116            "    > .git",
1117            "      [PROCESSING: 'new_dir']  <== selected",
1118            "      .dockerignore",
1119        ]
1120    );
1121
1122    confirm.await.unwrap();
1123    cx.run_until_parked();
1124    assert_eq!(
1125        visible_entries_as_strings(&panel, 0..10, cx),
1126        &[
1127            "v root1",
1128            "    > .git",
1129            "    v new_dir  <== selected",
1130            "      .dockerignore",
1131        ]
1132    );
1133
1134    // Test filename with whitespace
1135    select_path(&panel, "root1", cx);
1136    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1137    let confirm = panel.update_in(cx, |panel, window, cx| {
1138        // If we want to create a subdirectory, there should be no prefix slash.
1139        panel
1140            .filename_editor
1141            .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
1142        panel.confirm_edit(true, window, cx).unwrap()
1143    });
1144    confirm.await.unwrap();
1145    cx.run_until_parked();
1146    assert_eq!(
1147        visible_entries_as_strings(&panel, 0..10, cx),
1148        &[
1149            "v root1",
1150            "    > .git",
1151            "    v new dir 2  <== selected",
1152            "    v new_dir",
1153            "      .dockerignore",
1154        ]
1155    );
1156
1157    // Test filename ends with "\"
1158    #[cfg(target_os = "windows")]
1159    {
1160        select_path(&panel, "root1", cx);
1161        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1162        let confirm = panel.update_in(cx, |panel, window, cx| {
1163            // If we want to create a subdirectory, there should be no prefix slash.
1164            panel
1165                .filename_editor
1166                .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
1167            panel.confirm_edit(true, window, cx).unwrap()
1168        });
1169        confirm.await.unwrap();
1170        cx.run_until_parked();
1171        assert_eq!(
1172            visible_entries_as_strings(&panel, 0..10, cx),
1173            &[
1174                "v root1",
1175                "    > .git",
1176                "    v new dir 2",
1177                "    v new_dir",
1178                "    v new_dir_3  <== selected",
1179                "      .dockerignore",
1180            ]
1181        );
1182    }
1183}
1184
1185#[gpui::test]
1186async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1187    init_test(cx);
1188
1189    let fs = FakeFs::new(cx.executor());
1190    fs.insert_tree(
1191        "/root1",
1192        json!({
1193            "one.two.txt": "",
1194            "one.txt": ""
1195        }),
1196    )
1197    .await;
1198
1199    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1200    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1201    let workspace = window
1202        .read_with(cx, |mw, _| mw.workspace().clone())
1203        .unwrap();
1204    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1205    let panel = workspace.update_in(cx, ProjectPanel::new);
1206    cx.run_until_parked();
1207
1208    panel.update_in(cx, |panel, window, cx| {
1209        panel.select_next(&Default::default(), window, cx);
1210        panel.select_next(&Default::default(), window, cx);
1211    });
1212
1213    assert_eq!(
1214        visible_entries_as_strings(&panel, 0..50, cx),
1215        &[
1216            //
1217            "v root1",
1218            "      one.txt  <== selected",
1219            "      one.two.txt",
1220        ]
1221    );
1222
1223    // Regression test - file name is created correctly when
1224    // the copied file's name contains multiple dots.
1225    panel.update_in(cx, |panel, window, cx| {
1226        panel.copy(&Default::default(), window, cx);
1227        panel.paste(&Default::default(), window, cx);
1228    });
1229    cx.executor().run_until_parked();
1230    panel.update_in(cx, |panel, window, cx| {
1231        assert!(panel.filename_editor.read(cx).is_focused(window));
1232    });
1233    assert_eq!(
1234        visible_entries_as_strings(&panel, 0..50, cx),
1235        &[
1236            //
1237            "v root1",
1238            "      one.txt",
1239            "      [EDITOR: 'one copy.txt']  <== selected  <== marked",
1240            "      one.two.txt",
1241        ]
1242    );
1243
1244    panel.update_in(cx, |panel, window, cx| {
1245        panel.filename_editor.update(cx, |editor, cx| {
1246            let file_name_selections = editor
1247                .selections
1248                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1249            assert_eq!(
1250                file_name_selections.len(),
1251                1,
1252                "File editing should have a single selection, but got: {file_name_selections:?}"
1253            );
1254            let file_name_selection = &file_name_selections[0];
1255            assert_eq!(
1256                file_name_selection.start,
1257                MultiBufferOffset("one".len()),
1258                "Should select the file name disambiguation after the original file name"
1259            );
1260            assert_eq!(
1261                file_name_selection.end,
1262                MultiBufferOffset("one copy".len()),
1263                "Should select the file name disambiguation until the extension"
1264            );
1265        });
1266        assert!(panel.confirm_edit(true, window, cx).is_none());
1267    });
1268
1269    panel.update_in(cx, |panel, window, cx| {
1270        panel.paste(&Default::default(), window, cx);
1271    });
1272    cx.executor().run_until_parked();
1273    panel.update_in(cx, |panel, window, cx| {
1274        assert!(panel.filename_editor.read(cx).is_focused(window));
1275    });
1276    assert_eq!(
1277        visible_entries_as_strings(&panel, 0..50, cx),
1278        &[
1279            //
1280            "v root1",
1281            "      one.txt",
1282            "      one copy.txt",
1283            "      [EDITOR: 'one copy 1.txt']  <== selected  <== marked",
1284            "      one.two.txt",
1285        ]
1286    );
1287
1288    panel.update_in(cx, |panel, window, cx| {
1289        assert!(panel.confirm_edit(true, window, cx).is_none())
1290    });
1291}
1292
1293#[gpui::test]
1294async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
1295    init_test(cx);
1296
1297    let fs = FakeFs::new(cx.executor());
1298    fs.insert_tree(
1299        "/root",
1300        json!({
1301            "one.txt": "",
1302            "two.txt": "",
1303            "a": {},
1304            "b": {}
1305        }),
1306    )
1307    .await;
1308
1309    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1310    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1311    let workspace = window
1312        .read_with(cx, |mw, _| mw.workspace().clone())
1313        .unwrap();
1314    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1315    let panel = workspace.update_in(cx, ProjectPanel::new);
1316    cx.run_until_parked();
1317
1318    select_path_with_mark(&panel, "root/one.txt", cx);
1319    select_path_with_mark(&panel, "root/two.txt", cx);
1320
1321    assert_eq!(
1322        visible_entries_as_strings(&panel, 0..50, cx),
1323        &[
1324            "v root",
1325            "    > a",
1326            "    > b",
1327            "      one.txt  <== marked",
1328            "      two.txt  <== selected  <== marked",
1329        ]
1330    );
1331
1332    panel.update_in(cx, |panel, window, cx| {
1333        panel.cut(&Default::default(), window, cx);
1334    });
1335
1336    select_path(&panel, "root/a", cx);
1337
1338    panel.update_in(cx, |panel, window, cx| {
1339        panel.paste(&Default::default(), window, cx);
1340        panel.update_visible_entries(None, false, false, window, cx);
1341    });
1342    cx.executor().run_until_parked();
1343
1344    assert_eq!(
1345        visible_entries_as_strings(&panel, 0..50, cx),
1346        &[
1347            "v root",
1348            "    v a",
1349            "          one.txt  <== marked",
1350            "          two.txt  <== selected  <== marked",
1351            "    > b",
1352        ],
1353        "Cut entries should be moved on first paste."
1354    );
1355
1356    panel.update_in(cx, |panel, window, cx| {
1357        panel.cancel(&menu::Cancel {}, window, cx)
1358    });
1359    cx.executor().run_until_parked();
1360
1361    select_path(&panel, "root/b", cx);
1362
1363    panel.update_in(cx, |panel, window, cx| {
1364        panel.paste(&Default::default(), window, cx);
1365    });
1366    cx.executor().run_until_parked();
1367
1368    assert_eq!(
1369        visible_entries_as_strings(&panel, 0..50, cx),
1370        &[
1371            "v root",
1372            "    v a",
1373            "          one.txt",
1374            "          two.txt",
1375            "    v b",
1376            "          one.txt",
1377            "          two.txt  <== selected",
1378        ],
1379        "Cut entries should only be copied for the second paste!"
1380    );
1381}
1382
1383#[gpui::test]
1384async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1385    init_test(cx);
1386
1387    let fs = FakeFs::new(cx.executor());
1388    fs.insert_tree(
1389        "/root1",
1390        json!({
1391            "one.txt": "",
1392            "two.txt": "",
1393            "three.txt": "",
1394            "a": {
1395                "0": { "q": "", "r": "", "s": "" },
1396                "1": { "t": "", "u": "" },
1397                "2": { "v": "", "w": "", "x": "", "y": "" },
1398            },
1399        }),
1400    )
1401    .await;
1402
1403    fs.insert_tree(
1404        "/root2",
1405        json!({
1406            "one.txt": "",
1407            "two.txt": "",
1408            "four.txt": "",
1409            "b": {
1410                "3": { "Q": "" },
1411                "4": { "R": "", "S": "", "T": "", "U": "" },
1412            },
1413        }),
1414    )
1415    .await;
1416
1417    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1418    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1419    let workspace = window
1420        .read_with(cx, |mw, _| mw.workspace().clone())
1421        .unwrap();
1422    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1423    let panel = workspace.update_in(cx, ProjectPanel::new);
1424    cx.run_until_parked();
1425
1426    select_path(&panel, "root1/three.txt", cx);
1427    panel.update_in(cx, |panel, window, cx| {
1428        panel.cut(&Default::default(), window, cx);
1429    });
1430
1431    select_path(&panel, "root2/one.txt", cx);
1432    panel.update_in(cx, |panel, window, cx| {
1433        panel.select_next(&Default::default(), window, cx);
1434        panel.paste(&Default::default(), window, cx);
1435    });
1436    cx.executor().run_until_parked();
1437    assert_eq!(
1438        visible_entries_as_strings(&panel, 0..50, cx),
1439        &[
1440            //
1441            "v root1",
1442            "    > a",
1443            "      one.txt",
1444            "      two.txt",
1445            "v root2",
1446            "    > b",
1447            "      four.txt",
1448            "      one.txt",
1449            "      three.txt  <== selected  <== marked",
1450            "      two.txt",
1451        ]
1452    );
1453
1454    select_path(&panel, "root1/a", cx);
1455    panel.update_in(cx, |panel, window, cx| {
1456        panel.cut(&Default::default(), window, cx);
1457    });
1458    select_path(&panel, "root2/two.txt", cx);
1459    panel.update_in(cx, |panel, window, cx| {
1460        panel.select_next(&Default::default(), window, cx);
1461        panel.paste(&Default::default(), window, cx);
1462    });
1463
1464    cx.executor().run_until_parked();
1465    assert_eq!(
1466        visible_entries_as_strings(&panel, 0..50, cx),
1467        &[
1468            //
1469            "v root1",
1470            "      one.txt",
1471            "      two.txt",
1472            "v root2",
1473            "    > a  <== selected",
1474            "    > b",
1475            "      four.txt",
1476            "      one.txt",
1477            "      three.txt  <== marked",
1478            "      two.txt",
1479        ]
1480    );
1481}
1482
1483#[gpui::test]
1484async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1485    init_test(cx);
1486
1487    let fs = FakeFs::new(cx.executor());
1488    fs.insert_tree(
1489        "/root1",
1490        json!({
1491            "one.txt": "",
1492            "two.txt": "",
1493            "three.txt": "",
1494            "a": {
1495                "0": { "q": "", "r": "", "s": "" },
1496                "1": { "t": "", "u": "" },
1497                "2": { "v": "", "w": "", "x": "", "y": "" },
1498            },
1499        }),
1500    )
1501    .await;
1502
1503    fs.insert_tree(
1504        "/root2",
1505        json!({
1506            "one.txt": "",
1507            "two.txt": "",
1508            "four.txt": "",
1509            "b": {
1510                "3": { "Q": "" },
1511                "4": { "R": "", "S": "", "T": "", "U": "" },
1512            },
1513        }),
1514    )
1515    .await;
1516
1517    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1518    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1519    let workspace = window
1520        .read_with(cx, |mw, _| mw.workspace().clone())
1521        .unwrap();
1522    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1523    let panel = workspace.update_in(cx, ProjectPanel::new);
1524    cx.run_until_parked();
1525
1526    select_path(&panel, "root1/three.txt", cx);
1527    panel.update_in(cx, |panel, window, cx| {
1528        panel.copy(&Default::default(), window, cx);
1529    });
1530
1531    select_path(&panel, "root2/one.txt", cx);
1532    panel.update_in(cx, |panel, window, cx| {
1533        panel.select_next(&Default::default(), window, cx);
1534        panel.paste(&Default::default(), window, cx);
1535    });
1536    cx.executor().run_until_parked();
1537    assert_eq!(
1538        visible_entries_as_strings(&panel, 0..50, cx),
1539        &[
1540            //
1541            "v root1",
1542            "    > a",
1543            "      one.txt",
1544            "      three.txt",
1545            "      two.txt",
1546            "v root2",
1547            "    > b",
1548            "      four.txt",
1549            "      one.txt",
1550            "      three.txt  <== selected  <== marked",
1551            "      two.txt",
1552        ]
1553    );
1554
1555    select_path(&panel, "root1/three.txt", cx);
1556    panel.update_in(cx, |panel, window, cx| {
1557        panel.copy(&Default::default(), window, cx);
1558    });
1559    select_path(&panel, "root2/two.txt", cx);
1560    panel.update_in(cx, |panel, window, cx| {
1561        panel.select_next(&Default::default(), window, cx);
1562        panel.paste(&Default::default(), window, cx);
1563    });
1564
1565    cx.executor().run_until_parked();
1566    assert_eq!(
1567        visible_entries_as_strings(&panel, 0..50, cx),
1568        &[
1569            //
1570            "v root1",
1571            "    > a",
1572            "      one.txt",
1573            "      three.txt",
1574            "      two.txt",
1575            "v root2",
1576            "    > b",
1577            "      four.txt",
1578            "      one.txt",
1579            "      three.txt",
1580            "      [EDITOR: 'three copy.txt']  <== selected  <== marked",
1581            "      two.txt",
1582        ]
1583    );
1584
1585    panel.update_in(cx, |panel, window, cx| {
1586        panel.cancel(&menu::Cancel {}, window, cx)
1587    });
1588    cx.executor().run_until_parked();
1589
1590    select_path(&panel, "root1/a", cx);
1591    panel.update_in(cx, |panel, window, cx| {
1592        panel.copy(&Default::default(), window, cx);
1593    });
1594    select_path(&panel, "root2/two.txt", cx);
1595    panel.update_in(cx, |panel, window, cx| {
1596        panel.select_next(&Default::default(), window, cx);
1597        panel.paste(&Default::default(), window, cx);
1598    });
1599
1600    cx.executor().run_until_parked();
1601    assert_eq!(
1602        visible_entries_as_strings(&panel, 0..50, cx),
1603        &[
1604            //
1605            "v root1",
1606            "    > a",
1607            "      one.txt",
1608            "      three.txt",
1609            "      two.txt",
1610            "v root2",
1611            "    > a  <== selected",
1612            "    > b",
1613            "      four.txt",
1614            "      one.txt",
1615            "      three.txt",
1616            "      three copy.txt",
1617            "      two.txt",
1618        ]
1619    );
1620}
1621
1622#[gpui::test]
1623async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1624    init_test(cx);
1625
1626    let fs = FakeFs::new(cx.executor());
1627    fs.insert_tree(
1628        "/root",
1629        json!({
1630            "a": {
1631                "one.txt": "",
1632                "two.txt": "",
1633                "inner_dir": {
1634                    "three.txt": "",
1635                    "four.txt": "",
1636                }
1637            },
1638            "b": {}
1639        }),
1640    )
1641    .await;
1642
1643    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1644    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1645    let workspace = window
1646        .read_with(cx, |mw, _| mw.workspace().clone())
1647        .unwrap();
1648    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1649    let panel = workspace.update_in(cx, ProjectPanel::new);
1650    cx.run_until_parked();
1651
1652    select_path(&panel, "root/a", cx);
1653    panel.update_in(cx, |panel, window, cx| {
1654        panel.copy(&Default::default(), window, cx);
1655        panel.select_next(&Default::default(), window, cx);
1656        panel.paste(&Default::default(), window, cx);
1657    });
1658    cx.executor().run_until_parked();
1659
1660    let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1661    assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1662
1663    let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1664    assert_ne!(
1665        pasted_dir_file, None,
1666        "Pasted directory file should have an entry"
1667    );
1668
1669    let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1670    assert_ne!(
1671        pasted_dir_inner_dir, None,
1672        "Directories inside pasted directory should have an entry"
1673    );
1674
1675    toggle_expand_dir(&panel, "root/b/a", cx);
1676    toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1677
1678    assert_eq!(
1679        visible_entries_as_strings(&panel, 0..50, cx),
1680        &[
1681            //
1682            "v root",
1683            "    > a",
1684            "    v b",
1685            "        v a",
1686            "            v inner_dir  <== selected",
1687            "                  four.txt",
1688            "                  three.txt",
1689            "              one.txt",
1690            "              two.txt",
1691        ]
1692    );
1693
1694    select_path(&panel, "root", cx);
1695    panel.update_in(cx, |panel, window, cx| {
1696        panel.paste(&Default::default(), window, cx)
1697    });
1698    cx.executor().run_until_parked();
1699    assert_eq!(
1700        visible_entries_as_strings(&panel, 0..50, cx),
1701        &[
1702            //
1703            "v root",
1704            "    > a",
1705            "    > [EDITOR: 'a copy']  <== selected",
1706            "    v b",
1707            "        v a",
1708            "            v inner_dir",
1709            "                  four.txt",
1710            "                  three.txt",
1711            "              one.txt",
1712            "              two.txt"
1713        ]
1714    );
1715
1716    let confirm = panel.update_in(cx, |panel, window, cx| {
1717        panel
1718            .filename_editor
1719            .update(cx, |editor, cx| editor.set_text("c", window, cx));
1720        panel.confirm_edit(true, window, cx).unwrap()
1721    });
1722    assert_eq!(
1723        visible_entries_as_strings(&panel, 0..50, cx),
1724        &[
1725            //
1726            "v root",
1727            "    > a",
1728            "    > [PROCESSING: 'c']  <== selected",
1729            "    v b",
1730            "        v a",
1731            "            v inner_dir",
1732            "                  four.txt",
1733            "                  three.txt",
1734            "              one.txt",
1735            "              two.txt"
1736        ]
1737    );
1738
1739    confirm.await.unwrap();
1740
1741    panel.update_in(cx, |panel, window, cx| {
1742        panel.paste(&Default::default(), window, cx)
1743    });
1744    cx.executor().run_until_parked();
1745    assert_eq!(
1746        visible_entries_as_strings(&panel, 0..50, cx),
1747        &[
1748            //
1749            "v root",
1750            "    > a",
1751            "    v b",
1752            "        v a",
1753            "            v inner_dir",
1754            "                  four.txt",
1755            "                  three.txt",
1756            "              one.txt",
1757            "              two.txt",
1758            "    v c",
1759            "        > a  <== selected",
1760            "        > inner_dir",
1761            "          one.txt",
1762            "          two.txt",
1763        ]
1764    );
1765}
1766
1767#[gpui::test]
1768async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
1769    init_test(cx);
1770
1771    let fs = FakeFs::new(cx.executor());
1772    fs.insert_tree(
1773        "/test",
1774        json!({
1775            "dir1": {
1776                "a.txt": "",
1777                "b.txt": "",
1778            },
1779            "dir2": {},
1780            "c.txt": "",
1781            "d.txt": "",
1782        }),
1783    )
1784    .await;
1785
1786    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1787    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1788    let workspace = window
1789        .read_with(cx, |mw, _| mw.workspace().clone())
1790        .unwrap();
1791    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1792    let panel = workspace.update_in(cx, ProjectPanel::new);
1793    cx.run_until_parked();
1794
1795    toggle_expand_dir(&panel, "test/dir1", cx);
1796
1797    cx.simulate_modifiers_change(gpui::Modifiers {
1798        control: true,
1799        ..Default::default()
1800    });
1801
1802    select_path_with_mark(&panel, "test/dir1", cx);
1803    select_path_with_mark(&panel, "test/c.txt", cx);
1804
1805    assert_eq!(
1806        visible_entries_as_strings(&panel, 0..15, cx),
1807        &[
1808            "v test",
1809            "    v dir1  <== marked",
1810            "          a.txt",
1811            "          b.txt",
1812            "    > dir2",
1813            "      c.txt  <== selected  <== marked",
1814            "      d.txt",
1815        ],
1816        "Initial state before copying dir1 and c.txt"
1817    );
1818
1819    panel.update_in(cx, |panel, window, cx| {
1820        panel.copy(&Default::default(), window, cx);
1821    });
1822    select_path(&panel, "test/dir2", cx);
1823    panel.update_in(cx, |panel, window, cx| {
1824        panel.paste(&Default::default(), window, cx);
1825    });
1826    cx.executor().run_until_parked();
1827
1828    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1829
1830    assert_eq!(
1831        visible_entries_as_strings(&panel, 0..15, cx),
1832        &[
1833            "v test",
1834            "    v dir1  <== marked",
1835            "          a.txt",
1836            "          b.txt",
1837            "    v dir2",
1838            "        v dir1  <== selected",
1839            "              a.txt",
1840            "              b.txt",
1841            "          c.txt",
1842            "      c.txt  <== marked",
1843            "      d.txt",
1844        ],
1845        "Should copy dir1 as well as c.txt into dir2"
1846    );
1847
1848    // Disambiguating multiple files should not open the rename editor.
1849    select_path(&panel, "test/dir2", cx);
1850    panel.update_in(cx, |panel, window, cx| {
1851        panel.paste(&Default::default(), window, cx);
1852    });
1853    cx.executor().run_until_parked();
1854
1855    assert_eq!(
1856        visible_entries_as_strings(&panel, 0..15, cx),
1857        &[
1858            "v test",
1859            "    v dir1  <== marked",
1860            "          a.txt",
1861            "          b.txt",
1862            "    v dir2",
1863            "        v dir1",
1864            "              a.txt",
1865            "              b.txt",
1866            "        > dir1 copy  <== selected",
1867            "          c.txt",
1868            "          c copy.txt",
1869            "      c.txt  <== marked",
1870            "      d.txt",
1871        ],
1872        "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
1873    );
1874}
1875
1876#[gpui::test]
1877async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
1878    init_test(cx);
1879
1880    let fs = FakeFs::new(cx.executor());
1881    fs.insert_tree(
1882        "/test",
1883        json!({
1884            "dir1": {
1885                "a.txt": "",
1886                "b.txt": "",
1887            },
1888            "dir2": {},
1889            "c.txt": "",
1890            "d.txt": "",
1891        }),
1892    )
1893    .await;
1894
1895    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1896    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1897    let workspace = window
1898        .read_with(cx, |mw, _| mw.workspace().clone())
1899        .unwrap();
1900    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1901    let panel = workspace.update_in(cx, ProjectPanel::new);
1902    cx.run_until_parked();
1903
1904    toggle_expand_dir(&panel, "test/dir1", cx);
1905
1906    cx.simulate_modifiers_change(gpui::Modifiers {
1907        control: true,
1908        ..Default::default()
1909    });
1910
1911    select_path_with_mark(&panel, "test/dir1/a.txt", cx);
1912    select_path_with_mark(&panel, "test/dir1", cx);
1913    select_path_with_mark(&panel, "test/c.txt", cx);
1914
1915    assert_eq!(
1916        visible_entries_as_strings(&panel, 0..15, cx),
1917        &[
1918            "v test",
1919            "    v dir1  <== marked",
1920            "          a.txt  <== marked",
1921            "          b.txt",
1922            "    > dir2",
1923            "      c.txt  <== selected  <== marked",
1924            "      d.txt",
1925        ],
1926        "Initial state before copying a.txt, dir1 and c.txt"
1927    );
1928
1929    panel.update_in(cx, |panel, window, cx| {
1930        panel.copy(&Default::default(), window, cx);
1931    });
1932    select_path(&panel, "test/dir2", cx);
1933    panel.update_in(cx, |panel, window, cx| {
1934        panel.paste(&Default::default(), window, cx);
1935    });
1936    cx.executor().run_until_parked();
1937
1938    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1939
1940    assert_eq!(
1941        visible_entries_as_strings(&panel, 0..20, cx),
1942        &[
1943            "v test",
1944            "    v dir1  <== marked",
1945            "          a.txt  <== marked",
1946            "          b.txt",
1947            "    v dir2",
1948            "        v dir1  <== selected",
1949            "              a.txt",
1950            "              b.txt",
1951            "          c.txt",
1952            "      c.txt  <== marked",
1953            "      d.txt",
1954        ],
1955        "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
1956    );
1957}
1958
1959#[gpui::test]
1960async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1961    init_test_with_editor(cx);
1962
1963    let fs = FakeFs::new(cx.executor());
1964    fs.insert_tree(
1965        path!("/src"),
1966        json!({
1967            "test": {
1968                "first.rs": "// First Rust file",
1969                "second.rs": "// Second Rust file",
1970                "third.rs": "// Third Rust file",
1971            }
1972        }),
1973    )
1974    .await;
1975
1976    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
1977    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1978    let workspace = window
1979        .read_with(cx, |mw, _| mw.workspace().clone())
1980        .unwrap();
1981    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1982    let panel = workspace.update_in(cx, ProjectPanel::new);
1983    cx.run_until_parked();
1984
1985    toggle_expand_dir(&panel, "src/test", cx);
1986    select_path(&panel, "src/test/first.rs", cx);
1987    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1988    cx.executor().run_until_parked();
1989    assert_eq!(
1990        visible_entries_as_strings(&panel, 0..10, cx),
1991        &[
1992            "v src",
1993            "    v test",
1994            "          first.rs  <== selected  <== marked",
1995            "          second.rs",
1996            "          third.rs"
1997        ]
1998    );
1999    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2000
2001    submit_deletion(&panel, cx);
2002    assert_eq!(
2003        visible_entries_as_strings(&panel, 0..10, cx),
2004        &[
2005            "v src",
2006            "    v test",
2007            "          second.rs  <== selected",
2008            "          third.rs"
2009        ],
2010        "Project panel should have no deleted file, no other file is selected in it"
2011    );
2012    ensure_no_open_items_and_panes(&workspace, cx);
2013
2014    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2015    cx.executor().run_until_parked();
2016    assert_eq!(
2017        visible_entries_as_strings(&panel, 0..10, cx),
2018        &[
2019            "v src",
2020            "    v test",
2021            "          second.rs  <== selected  <== marked",
2022            "          third.rs"
2023        ]
2024    );
2025    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2026
2027    workspace.update_in(cx, |workspace, window, cx| {
2028        let active_items = workspace
2029            .panes()
2030            .iter()
2031            .filter_map(|pane| pane.read(cx).active_item())
2032            .collect::<Vec<_>>();
2033        assert_eq!(active_items.len(), 1);
2034        let open_editor = active_items
2035            .into_iter()
2036            .next()
2037            .unwrap()
2038            .downcast::<Editor>()
2039            .expect("Open item should be an editor");
2040        open_editor.update(cx, |editor, cx| {
2041            editor.set_text("Another text!", window, cx)
2042        });
2043    });
2044    submit_deletion_skipping_prompt(&panel, cx);
2045    assert_eq!(
2046        visible_entries_as_strings(&panel, 0..10, cx),
2047        &["v src", "    v test", "          third.rs  <== selected"],
2048        "Project panel should have no deleted file, with one last file remaining"
2049    );
2050    ensure_no_open_items_and_panes(&workspace, cx);
2051}
2052
2053#[gpui::test]
2054async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) {
2055    init_test_with_editor(cx);
2056    set_auto_open_settings(
2057        cx,
2058        ProjectPanelAutoOpenSettings {
2059            on_create: Some(true),
2060            ..Default::default()
2061        },
2062    );
2063
2064    let fs = FakeFs::new(cx.executor());
2065    fs.insert_tree(path!("/root"), json!({})).await;
2066
2067    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2068    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2069    let workspace = window
2070        .read_with(cx, |mw, _| mw.workspace().clone())
2071        .unwrap();
2072    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2073    let panel = workspace.update_in(cx, ProjectPanel::new);
2074    cx.run_until_parked();
2075
2076    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2077    cx.run_until_parked();
2078    panel
2079        .update_in(cx, |panel, window, cx| {
2080            panel.filename_editor.update(cx, |editor, cx| {
2081                editor.set_text("auto-open.rs", window, cx);
2082            });
2083            panel.confirm_edit(true, window, cx).unwrap()
2084        })
2085        .await
2086        .unwrap();
2087    cx.run_until_parked();
2088
2089    ensure_single_file_is_opened(&workspace, "auto-open.rs", cx);
2090}
2091
2092#[gpui::test]
2093async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) {
2094    init_test_with_editor(cx);
2095    set_auto_open_settings(
2096        cx,
2097        ProjectPanelAutoOpenSettings {
2098            on_create: Some(false),
2099            ..Default::default()
2100        },
2101    );
2102
2103    let fs = FakeFs::new(cx.executor());
2104    fs.insert_tree(path!("/root"), json!({})).await;
2105
2106    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2107    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2108    let workspace = window
2109        .read_with(cx, |mw, _| mw.workspace().clone())
2110        .unwrap();
2111    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2112    let panel = workspace.update_in(cx, ProjectPanel::new);
2113    cx.run_until_parked();
2114
2115    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2116    cx.run_until_parked();
2117    panel
2118        .update_in(cx, |panel, window, cx| {
2119            panel.filename_editor.update(cx, |editor, cx| {
2120                editor.set_text("manual-open.rs", window, cx);
2121            });
2122            panel.confirm_edit(true, window, cx).unwrap()
2123        })
2124        .await
2125        .unwrap();
2126    cx.run_until_parked();
2127
2128    ensure_no_open_items_and_panes(&workspace, cx);
2129}
2130
2131#[gpui::test]
2132async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) {
2133    init_test_with_editor(cx);
2134    set_auto_open_settings(
2135        cx,
2136        ProjectPanelAutoOpenSettings {
2137            on_paste: Some(true),
2138            ..Default::default()
2139        },
2140    );
2141
2142    let fs = FakeFs::new(cx.executor());
2143    fs.insert_tree(
2144        path!("/root"),
2145        json!({
2146            "src": {
2147                "original.rs": ""
2148            },
2149            "target": {}
2150        }),
2151    )
2152    .await;
2153
2154    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2155    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2156    let workspace = window
2157        .read_with(cx, |mw, _| mw.workspace().clone())
2158        .unwrap();
2159    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2160    let panel = workspace.update_in(cx, ProjectPanel::new);
2161    cx.run_until_parked();
2162
2163    toggle_expand_dir(&panel, "root/src", cx);
2164    toggle_expand_dir(&panel, "root/target", cx);
2165
2166    select_path(&panel, "root/src/original.rs", cx);
2167    panel.update_in(cx, |panel, window, cx| {
2168        panel.copy(&Default::default(), window, cx);
2169    });
2170
2171    select_path(&panel, "root/target", cx);
2172    panel.update_in(cx, |panel, window, cx| {
2173        panel.paste(&Default::default(), window, cx);
2174    });
2175    cx.executor().run_until_parked();
2176
2177    ensure_single_file_is_opened(&workspace, "target/original.rs", cx);
2178}
2179
2180#[gpui::test]
2181async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) {
2182    init_test_with_editor(cx);
2183    set_auto_open_settings(
2184        cx,
2185        ProjectPanelAutoOpenSettings {
2186            on_paste: Some(false),
2187            ..Default::default()
2188        },
2189    );
2190
2191    let fs = FakeFs::new(cx.executor());
2192    fs.insert_tree(
2193        path!("/root"),
2194        json!({
2195            "src": {
2196                "original.rs": ""
2197            },
2198            "target": {}
2199        }),
2200    )
2201    .await;
2202
2203    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2204    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2205    let workspace = window
2206        .read_with(cx, |mw, _| mw.workspace().clone())
2207        .unwrap();
2208    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2209    let panel = workspace.update_in(cx, ProjectPanel::new);
2210    cx.run_until_parked();
2211
2212    toggle_expand_dir(&panel, "root/src", cx);
2213    toggle_expand_dir(&panel, "root/target", cx);
2214
2215    select_path(&panel, "root/src/original.rs", cx);
2216    panel.update_in(cx, |panel, window, cx| {
2217        panel.copy(&Default::default(), window, cx);
2218    });
2219
2220    select_path(&panel, "root/target", cx);
2221    panel.update_in(cx, |panel, window, cx| {
2222        panel.paste(&Default::default(), window, cx);
2223    });
2224    cx.executor().run_until_parked();
2225
2226    ensure_no_open_items_and_panes(&workspace, cx);
2227    assert!(
2228        find_project_entry(&panel, "root/target/original.rs", cx).is_some(),
2229        "Pasted entry should exist even when auto-open is disabled"
2230    );
2231}
2232
2233#[gpui::test]
2234async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) {
2235    init_test_with_editor(cx);
2236    set_auto_open_settings(
2237        cx,
2238        ProjectPanelAutoOpenSettings {
2239            on_drop: Some(true),
2240            ..Default::default()
2241        },
2242    );
2243
2244    let fs = FakeFs::new(cx.executor());
2245    fs.insert_tree(path!("/root"), json!({})).await;
2246
2247    let temp_dir = tempfile::tempdir().unwrap();
2248    let external_path = temp_dir.path().join("dropped.rs");
2249    std::fs::write(&external_path, "// dropped").unwrap();
2250    fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2251        .await;
2252
2253    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2254    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2255    let workspace = window
2256        .read_with(cx, |mw, _| mw.workspace().clone())
2257        .unwrap();
2258    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2259    let panel = workspace.update_in(cx, ProjectPanel::new);
2260    cx.run_until_parked();
2261
2262    let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2263    panel.update_in(cx, |panel, window, cx| {
2264        panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2265    });
2266    cx.executor().run_until_parked();
2267
2268    ensure_single_file_is_opened(&workspace, "dropped.rs", cx);
2269}
2270
2271#[gpui::test]
2272async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) {
2273    init_test_with_editor(cx);
2274    set_auto_open_settings(
2275        cx,
2276        ProjectPanelAutoOpenSettings {
2277            on_drop: Some(false),
2278            ..Default::default()
2279        },
2280    );
2281
2282    let fs = FakeFs::new(cx.executor());
2283    fs.insert_tree(path!("/root"), json!({})).await;
2284
2285    let temp_dir = tempfile::tempdir().unwrap();
2286    let external_path = temp_dir.path().join("manual.rs");
2287    std::fs::write(&external_path, "// dropped").unwrap();
2288    fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2289        .await;
2290
2291    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2292    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2293    let workspace = window
2294        .read_with(cx, |mw, _| mw.workspace().clone())
2295        .unwrap();
2296    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2297    let panel = workspace.update_in(cx, ProjectPanel::new);
2298    cx.run_until_parked();
2299
2300    let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2301    panel.update_in(cx, |panel, window, cx| {
2302        panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2303    });
2304    cx.executor().run_until_parked();
2305
2306    ensure_no_open_items_and_panes(&workspace, cx);
2307    assert!(
2308        find_project_entry(&panel, "root/manual.rs", cx).is_some(),
2309        "Dropped entry should exist even when auto-open is disabled"
2310    );
2311}
2312
2313#[gpui::test]
2314async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2315    init_test_with_editor(cx);
2316
2317    let fs = FakeFs::new(cx.executor());
2318    fs.insert_tree(
2319        "/src",
2320        json!({
2321            "test": {
2322                "first.rs": "// First Rust file",
2323                "second.rs": "// Second Rust file",
2324                "third.rs": "// Third Rust file",
2325            }
2326        }),
2327    )
2328    .await;
2329
2330    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2331    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2332    let workspace = window
2333        .read_with(cx, |mw, _| mw.workspace().clone())
2334        .unwrap();
2335    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2336    let panel = workspace.update_in(cx, |workspace, window, cx| {
2337        let panel = ProjectPanel::new(workspace, window, cx);
2338        workspace.add_panel(panel.clone(), window, cx);
2339        panel
2340    });
2341    cx.run_until_parked();
2342
2343    select_path(&panel, "src", cx);
2344    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2345    cx.executor().run_until_parked();
2346    assert_eq!(
2347        visible_entries_as_strings(&panel, 0..10, cx),
2348        &[
2349            //
2350            "v src  <== selected",
2351            "    > test"
2352        ]
2353    );
2354    panel.update_in(cx, |panel, window, cx| {
2355        panel.new_directory(&NewDirectory, window, cx)
2356    });
2357    cx.run_until_parked();
2358    panel.update_in(cx, |panel, window, cx| {
2359        assert!(panel.filename_editor.read(cx).is_focused(window));
2360    });
2361    cx.executor().run_until_parked();
2362    assert_eq!(
2363        visible_entries_as_strings(&panel, 0..10, cx),
2364        &[
2365            //
2366            "v src",
2367            "    > [EDITOR: '']  <== selected",
2368            "    > test"
2369        ]
2370    );
2371    panel.update_in(cx, |panel, window, cx| {
2372        panel
2373            .filename_editor
2374            .update(cx, |editor, cx| editor.set_text("test", window, cx));
2375        assert!(
2376            panel.confirm_edit(true, window, cx).is_none(),
2377            "Should not allow to confirm on conflicting new directory name"
2378        );
2379    });
2380    cx.executor().run_until_parked();
2381    panel.update_in(cx, |panel, window, cx| {
2382        assert!(
2383            panel.state.edit_state.is_some(),
2384            "Edit state should not be None after conflicting new directory name"
2385        );
2386        panel.cancel(&menu::Cancel, window, cx);
2387    });
2388    cx.run_until_parked();
2389    assert_eq!(
2390        visible_entries_as_strings(&panel, 0..10, cx),
2391        &[
2392            //
2393            "v src  <== selected",
2394            "    > test"
2395        ],
2396        "File list should be unchanged after failed folder create confirmation"
2397    );
2398
2399    select_path(&panel, "src/test", cx);
2400    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2401    cx.executor().run_until_parked();
2402    assert_eq!(
2403        visible_entries_as_strings(&panel, 0..10, cx),
2404        &[
2405            //
2406            "v src",
2407            "    > test  <== selected"
2408        ]
2409    );
2410    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2411    cx.run_until_parked();
2412    panel.update_in(cx, |panel, window, cx| {
2413        assert!(panel.filename_editor.read(cx).is_focused(window));
2414    });
2415    assert_eq!(
2416        visible_entries_as_strings(&panel, 0..10, cx),
2417        &[
2418            "v src",
2419            "    v test",
2420            "          [EDITOR: '']  <== selected",
2421            "          first.rs",
2422            "          second.rs",
2423            "          third.rs"
2424        ]
2425    );
2426    panel.update_in(cx, |panel, window, cx| {
2427        panel
2428            .filename_editor
2429            .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2430        assert!(
2431            panel.confirm_edit(true, window, cx).is_none(),
2432            "Should not allow to confirm on conflicting new file name"
2433        );
2434    });
2435    cx.executor().run_until_parked();
2436    panel.update_in(cx, |panel, window, cx| {
2437        assert!(
2438            panel.state.edit_state.is_some(),
2439            "Edit state should not be None after conflicting new file name"
2440        );
2441        panel.cancel(&menu::Cancel, window, cx);
2442    });
2443    cx.run_until_parked();
2444    assert_eq!(
2445        visible_entries_as_strings(&panel, 0..10, cx),
2446        &[
2447            "v src",
2448            "    v test  <== selected",
2449            "          first.rs",
2450            "          second.rs",
2451            "          third.rs"
2452        ],
2453        "File list should be unchanged after failed file create confirmation"
2454    );
2455
2456    select_path(&panel, "src/test/first.rs", cx);
2457    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2458    cx.executor().run_until_parked();
2459    assert_eq!(
2460        visible_entries_as_strings(&panel, 0..10, cx),
2461        &[
2462            "v src",
2463            "    v test",
2464            "          first.rs  <== selected",
2465            "          second.rs",
2466            "          third.rs"
2467        ],
2468    );
2469    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2470    cx.executor().run_until_parked();
2471    panel.update_in(cx, |panel, window, cx| {
2472        assert!(panel.filename_editor.read(cx).is_focused(window));
2473    });
2474    assert_eq!(
2475        visible_entries_as_strings(&panel, 0..10, cx),
2476        &[
2477            "v src",
2478            "    v test",
2479            "          [EDITOR: 'first.rs']  <== selected",
2480            "          second.rs",
2481            "          third.rs"
2482        ]
2483    );
2484    panel.update_in(cx, |panel, window, cx| {
2485        panel
2486            .filename_editor
2487            .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2488        assert!(
2489            panel.confirm_edit(true, window, cx).is_none(),
2490            "Should not allow to confirm on conflicting file rename"
2491        )
2492    });
2493    cx.executor().run_until_parked();
2494    panel.update_in(cx, |panel, window, cx| {
2495        assert!(
2496            panel.state.edit_state.is_some(),
2497            "Edit state should not be None after conflicting file rename"
2498        );
2499        panel.cancel(&menu::Cancel, window, cx);
2500    });
2501    cx.executor().run_until_parked();
2502    assert_eq!(
2503        visible_entries_as_strings(&panel, 0..10, cx),
2504        &[
2505            "v src",
2506            "    v test",
2507            "          first.rs  <== selected",
2508            "          second.rs",
2509            "          third.rs"
2510        ],
2511        "File list should be unchanged after failed rename confirmation"
2512    );
2513}
2514
2515// NOTE: This test is skipped on Windows, because on Windows,
2516// when it triggers the lsp store it converts `/src/test/first copy.txt` into an uri
2517// but it fails with message `"/src\\test\\first copy.txt" is not parseable as an URI`
2518#[gpui::test]
2519#[cfg_attr(target_os = "windows", ignore)]
2520async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppContext) {
2521    init_test_with_editor(cx);
2522
2523    let fs = FakeFs::new(cx.executor());
2524    fs.insert_tree(
2525        "/src",
2526        json!({
2527            "test": {
2528                "first.txt": "// First Txt file",
2529                "second.txt": "// Second Txt file",
2530                "third.txt": "// Third Txt file",
2531            }
2532        }),
2533    )
2534    .await;
2535
2536    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2537    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2538    let workspace = window
2539        .read_with(cx, |mw, _| mw.workspace().clone())
2540        .unwrap();
2541    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2542    let panel = workspace.update_in(cx, |workspace, window, cx| {
2543        let panel = ProjectPanel::new(workspace, window, cx);
2544        workspace.add_panel(panel.clone(), window, cx);
2545        panel
2546    });
2547    cx.run_until_parked();
2548
2549    select_path(&panel, "src", cx);
2550    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2551    cx.executor().run_until_parked();
2552    assert_eq!(
2553        visible_entries_as_strings(&panel, 0..10, cx),
2554        &[
2555            //
2556            "v src  <== selected",
2557            "    > test"
2558        ]
2559    );
2560    panel.update_in(cx, |panel, window, cx| {
2561        panel.new_directory(&NewDirectory, window, cx)
2562    });
2563    cx.run_until_parked();
2564    panel.update_in(cx, |panel, window, cx| {
2565        assert!(panel.filename_editor.read(cx).is_focused(window));
2566    });
2567    cx.executor().run_until_parked();
2568    assert_eq!(
2569        visible_entries_as_strings(&panel, 0..10, cx),
2570        &[
2571            //
2572            "v src",
2573            "    > [EDITOR: '']  <== selected",
2574            "    > test"
2575        ]
2576    );
2577    panel.update_in(cx, |panel, window, cx| {
2578        panel
2579            .filename_editor
2580            .update(cx, |editor, cx| editor.set_text("test", window, cx));
2581        assert!(
2582            panel.confirm_edit(true, window, cx).is_none(),
2583            "Should not allow to confirm on conflicting new directory name"
2584        );
2585    });
2586    cx.executor().run_until_parked();
2587    panel.update_in(cx, |panel, window, cx| {
2588        assert!(
2589            panel.state.edit_state.is_some(),
2590            "Edit state should not be None after conflicting new directory name"
2591        );
2592        panel.cancel(&menu::Cancel, window, cx);
2593    });
2594    cx.run_until_parked();
2595    assert_eq!(
2596        visible_entries_as_strings(&panel, 0..10, cx),
2597        &[
2598            //
2599            "v src  <== selected",
2600            "    > test"
2601        ],
2602        "File list should be unchanged after failed folder create confirmation"
2603    );
2604
2605    select_path(&panel, "src/test", cx);
2606    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2607    cx.executor().run_until_parked();
2608    assert_eq!(
2609        visible_entries_as_strings(&panel, 0..10, cx),
2610        &[
2611            //
2612            "v src",
2613            "    > test  <== selected"
2614        ]
2615    );
2616    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2617    cx.run_until_parked();
2618    panel.update_in(cx, |panel, window, cx| {
2619        assert!(panel.filename_editor.read(cx).is_focused(window));
2620    });
2621    assert_eq!(
2622        visible_entries_as_strings(&panel, 0..10, cx),
2623        &[
2624            "v src",
2625            "    v test",
2626            "          [EDITOR: '']  <== selected",
2627            "          first.txt",
2628            "          second.txt",
2629            "          third.txt"
2630        ]
2631    );
2632    panel.update_in(cx, |panel, window, cx| {
2633        panel
2634            .filename_editor
2635            .update(cx, |editor, cx| editor.set_text("first.txt", window, cx));
2636        assert!(
2637            panel.confirm_edit(true, window, cx).is_none(),
2638            "Should not allow to confirm on conflicting new file name"
2639        );
2640    });
2641    cx.executor().run_until_parked();
2642    panel.update_in(cx, |panel, window, cx| {
2643        assert!(
2644            panel.state.edit_state.is_some(),
2645            "Edit state should not be None after conflicting new file name"
2646        );
2647        panel.cancel(&menu::Cancel, window, cx);
2648    });
2649    cx.run_until_parked();
2650    assert_eq!(
2651        visible_entries_as_strings(&panel, 0..10, cx),
2652        &[
2653            "v src",
2654            "    v test  <== selected",
2655            "          first.txt",
2656            "          second.txt",
2657            "          third.txt"
2658        ],
2659        "File list should be unchanged after failed file create confirmation"
2660    );
2661
2662    select_path(&panel, "src/test/first.txt", cx);
2663    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2664    cx.executor().run_until_parked();
2665    assert_eq!(
2666        visible_entries_as_strings(&panel, 0..10, cx),
2667        &[
2668            "v src",
2669            "    v test",
2670            "          first.txt  <== selected",
2671            "          second.txt",
2672            "          third.txt"
2673        ],
2674    );
2675    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2676    cx.executor().run_until_parked();
2677    panel.update_in(cx, |panel, window, cx| {
2678        assert!(panel.filename_editor.read(cx).is_focused(window));
2679    });
2680    assert_eq!(
2681        visible_entries_as_strings(&panel, 0..10, cx),
2682        &[
2683            "v src",
2684            "    v test",
2685            "          [EDITOR: 'first.txt']  <== selected",
2686            "          second.txt",
2687            "          third.txt"
2688        ]
2689    );
2690    panel.update_in(cx, |panel, window, cx| {
2691        panel
2692            .filename_editor
2693            .update(cx, |editor, cx| editor.set_text("second.txt", window, cx));
2694        assert!(
2695            panel.confirm_edit(true, window, cx).is_none(),
2696            "Should not allow to confirm on conflicting file rename"
2697        )
2698    });
2699    cx.executor().run_until_parked();
2700    panel.update_in(cx, |panel, window, cx| {
2701        assert!(
2702            panel.state.edit_state.is_some(),
2703            "Edit state should not be None after conflicting file rename"
2704        );
2705        panel.cancel(&menu::Cancel, window, cx);
2706    });
2707    cx.executor().run_until_parked();
2708    assert_eq!(
2709        visible_entries_as_strings(&panel, 0..10, cx),
2710        &[
2711            "v src",
2712            "    v test",
2713            "          first.txt  <== selected",
2714            "          second.txt",
2715            "          third.txt"
2716        ],
2717        "File list should be unchanged after failed rename confirmation"
2718    );
2719    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2720    cx.executor().run_until_parked();
2721    // Try to duplicate and check history
2722    panel.update_in(cx, |panel, window, cx| {
2723        panel.duplicate(&Duplicate, window, cx)
2724    });
2725    cx.executor().run_until_parked();
2726
2727    assert_eq!(
2728        visible_entries_as_strings(&panel, 0..10, cx),
2729        &[
2730            "v src",
2731            "    v test",
2732            "          first.txt",
2733            "          [EDITOR: 'first copy.txt']  <== selected  <== marked",
2734            "          second.txt",
2735            "          third.txt"
2736        ],
2737    );
2738
2739    let confirm = panel.update_in(cx, |panel, window, cx| {
2740        panel
2741            .filename_editor
2742            .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2743        panel.confirm_edit(true, window, cx).unwrap()
2744    });
2745    confirm.await.unwrap();
2746    cx.executor().run_until_parked();
2747
2748    assert_eq!(
2749        visible_entries_as_strings(&panel, 0..10, cx),
2750        &[
2751            "v src",
2752            "    v test",
2753            "          first.txt",
2754            "          fourth.txt  <== selected",
2755            "          second.txt",
2756            "          third.txt"
2757        ],
2758        "File list should be different after rename confirmation"
2759    );
2760
2761    panel.update_in(cx, |panel, window, cx| {
2762        panel.update_visible_entries(None, false, false, window, cx);
2763    });
2764    cx.executor().run_until_parked();
2765
2766    select_path(&panel, "src/test/first.txt", cx);
2767    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2768    cx.executor().run_until_parked();
2769
2770    workspace.read_with(cx, |this, cx| {
2771        assert!(
2772            this.recent_navigation_history_iter(cx)
2773                .any(|(project_path, abs_path)| {
2774                    project_path.path == Arc::from(rel_path("test/fourth.txt"))
2775                        && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2776                })
2777        );
2778    });
2779}
2780
2781// NOTE: This test is skipped on Windows, because on Windows,
2782// when it triggers the lsp store it converts `/src/test/first.txt` into an uri
2783// but it fails with message `"/src\\test\\first.txt" is not parseable as an URI`
2784#[gpui::test]
2785#[cfg_attr(target_os = "windows", ignore)]
2786async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) {
2787    init_test_with_editor(cx);
2788
2789    let fs = FakeFs::new(cx.executor());
2790    fs.insert_tree(
2791        "/src",
2792        json!({
2793            "test": {
2794                "first.txt": "// First Txt file",
2795                "second.txt": "// Second Txt file",
2796                "third.txt": "// Third Txt file",
2797            }
2798        }),
2799    )
2800    .await;
2801
2802    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2803    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2804    let workspace = window
2805        .read_with(cx, |mw, _| mw.workspace().clone())
2806        .unwrap();
2807    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2808    let panel = workspace.update_in(cx, |workspace, window, cx| {
2809        let panel = ProjectPanel::new(workspace, window, cx);
2810        workspace.add_panel(panel.clone(), window, cx);
2811        panel
2812    });
2813    cx.run_until_parked();
2814
2815    select_path(&panel, "src", cx);
2816    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2817    cx.executor().run_until_parked();
2818    assert_eq!(
2819        visible_entries_as_strings(&panel, 0..10, cx),
2820        &[
2821            //
2822            "v src  <== selected",
2823            "    > test"
2824        ]
2825    );
2826
2827    select_path(&panel, "src/test", cx);
2828    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2829    cx.executor().run_until_parked();
2830    assert_eq!(
2831        visible_entries_as_strings(&panel, 0..10, cx),
2832        &[
2833            //
2834            "v src",
2835            "    > test  <== selected"
2836        ]
2837    );
2838    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2839    cx.run_until_parked();
2840    panel.update_in(cx, |panel, window, cx| {
2841        assert!(panel.filename_editor.read(cx).is_focused(window));
2842    });
2843
2844    select_path(&panel, "src/test/first.txt", cx);
2845    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2846    cx.executor().run_until_parked();
2847
2848    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2849    cx.executor().run_until_parked();
2850
2851    assert_eq!(
2852        visible_entries_as_strings(&panel, 0..10, cx),
2853        &[
2854            "v src",
2855            "    v test",
2856            "          [EDITOR: 'first.txt']  <== selected  <== marked",
2857            "          second.txt",
2858            "          third.txt"
2859        ],
2860    );
2861
2862    let confirm = panel.update_in(cx, |panel, window, cx| {
2863        panel
2864            .filename_editor
2865            .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2866        panel.confirm_edit(true, window, cx).unwrap()
2867    });
2868    confirm.await.unwrap();
2869    cx.executor().run_until_parked();
2870
2871    assert_eq!(
2872        visible_entries_as_strings(&panel, 0..10, cx),
2873        &[
2874            "v src",
2875            "    v test",
2876            "          fourth.txt  <== selected",
2877            "          second.txt",
2878            "          third.txt"
2879        ],
2880        "File list should be different after rename confirmation"
2881    );
2882
2883    panel.update_in(cx, |panel, window, cx| {
2884        panel.update_visible_entries(None, false, false, window, cx);
2885    });
2886    cx.executor().run_until_parked();
2887
2888    select_path(&panel, "src/test/second.txt", cx);
2889    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2890    cx.executor().run_until_parked();
2891
2892    workspace.read_with(cx, |this, cx| {
2893        assert!(
2894            this.recent_navigation_history_iter(cx)
2895                .any(|(project_path, abs_path)| {
2896                    project_path.path == Arc::from(rel_path("test/fourth.txt"))
2897                        && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2898                })
2899        );
2900    });
2901}
2902
2903#[gpui::test]
2904async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
2905    init_test_with_editor(cx);
2906
2907    let fs = FakeFs::new(cx.executor());
2908    fs.insert_tree(
2909        path!("/root"),
2910        json!({
2911            "tree1": {
2912                ".git": {},
2913                "dir1": {
2914                    "modified1.txt": "1",
2915                    "unmodified1.txt": "1",
2916                    "modified2.txt": "1",
2917                },
2918                "dir2": {
2919                    "modified3.txt": "1",
2920                    "unmodified2.txt": "1",
2921                },
2922                "modified4.txt": "1",
2923                "unmodified3.txt": "1",
2924            },
2925            "tree2": {
2926                ".git": {},
2927                "dir3": {
2928                    "modified5.txt": "1",
2929                    "unmodified4.txt": "1",
2930                },
2931                "modified6.txt": "1",
2932                "unmodified5.txt": "1",
2933            }
2934        }),
2935    )
2936    .await;
2937
2938    // Mark files as git modified
2939    fs.set_head_and_index_for_repo(
2940        path!("/root/tree1/.git").as_ref(),
2941        &[
2942            ("dir1/modified1.txt", "modified".into()),
2943            ("dir1/modified2.txt", "modified".into()),
2944            ("modified4.txt", "modified".into()),
2945            ("dir2/modified3.txt", "modified".into()),
2946        ],
2947    );
2948    fs.set_head_and_index_for_repo(
2949        path!("/root/tree2/.git").as_ref(),
2950        &[
2951            ("dir3/modified5.txt", "modified".into()),
2952            ("modified6.txt", "modified".into()),
2953        ],
2954    );
2955
2956    let project = Project::test(
2957        fs.clone(),
2958        [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2959        cx,
2960    )
2961    .await;
2962
2963    let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
2964        let mut worktrees = project.worktrees(cx);
2965        let worktree1 = worktrees.next().unwrap();
2966        let worktree2 = worktrees.next().unwrap();
2967        (
2968            worktree1.read(cx).as_local().unwrap().scan_complete(),
2969            worktree2.read(cx).as_local().unwrap().scan_complete(),
2970        )
2971    });
2972    scan1_complete.await;
2973    scan2_complete.await;
2974    cx.run_until_parked();
2975
2976    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2977    let workspace = window
2978        .read_with(cx, |mw, _| mw.workspace().clone())
2979        .unwrap();
2980    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2981    let panel = workspace.update_in(cx, ProjectPanel::new);
2982    cx.run_until_parked();
2983
2984    // Check initial state
2985    assert_eq!(
2986        visible_entries_as_strings(&panel, 0..15, cx),
2987        &[
2988            "v tree1",
2989            "    > .git",
2990            "    > dir1",
2991            "    > dir2",
2992            "      modified4.txt",
2993            "      unmodified3.txt",
2994            "v tree2",
2995            "    > .git",
2996            "    > dir3",
2997            "      modified6.txt",
2998            "      unmodified5.txt"
2999        ],
3000    );
3001
3002    // Test selecting next modified entry
3003    panel.update_in(cx, |panel, window, cx| {
3004        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3005    });
3006    cx.run_until_parked();
3007
3008    assert_eq!(
3009        visible_entries_as_strings(&panel, 0..6, cx),
3010        &[
3011            "v tree1",
3012            "    > .git",
3013            "    v dir1",
3014            "          modified1.txt  <== selected",
3015            "          modified2.txt",
3016            "          unmodified1.txt",
3017        ],
3018    );
3019
3020    panel.update_in(cx, |panel, window, cx| {
3021        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3022    });
3023    cx.run_until_parked();
3024
3025    assert_eq!(
3026        visible_entries_as_strings(&panel, 0..6, cx),
3027        &[
3028            "v tree1",
3029            "    > .git",
3030            "    v dir1",
3031            "          modified1.txt",
3032            "          modified2.txt  <== selected",
3033            "          unmodified1.txt",
3034        ],
3035    );
3036
3037    panel.update_in(cx, |panel, window, cx| {
3038        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3039    });
3040    cx.run_until_parked();
3041
3042    assert_eq!(
3043        visible_entries_as_strings(&panel, 6..9, cx),
3044        &[
3045            "    v dir2",
3046            "          modified3.txt  <== selected",
3047            "          unmodified2.txt",
3048        ],
3049    );
3050
3051    panel.update_in(cx, |panel, window, cx| {
3052        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3053    });
3054    cx.run_until_parked();
3055
3056    assert_eq!(
3057        visible_entries_as_strings(&panel, 9..11, cx),
3058        &["      modified4.txt  <== selected", "      unmodified3.txt",],
3059    );
3060
3061    panel.update_in(cx, |panel, window, cx| {
3062        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3063    });
3064    cx.run_until_parked();
3065
3066    assert_eq!(
3067        visible_entries_as_strings(&panel, 13..16, cx),
3068        &[
3069            "    v dir3",
3070            "          modified5.txt  <== selected",
3071            "          unmodified4.txt",
3072        ],
3073    );
3074
3075    panel.update_in(cx, |panel, window, cx| {
3076        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3077    });
3078    cx.run_until_parked();
3079
3080    assert_eq!(
3081        visible_entries_as_strings(&panel, 16..18, cx),
3082        &["      modified6.txt  <== selected", "      unmodified5.txt",],
3083    );
3084
3085    // Wraps around to first modified file
3086    panel.update_in(cx, |panel, window, cx| {
3087        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3088    });
3089    cx.run_until_parked();
3090
3091    assert_eq!(
3092        visible_entries_as_strings(&panel, 0..18, cx),
3093        &[
3094            "v tree1",
3095            "    > .git",
3096            "    v dir1",
3097            "          modified1.txt  <== selected",
3098            "          modified2.txt",
3099            "          unmodified1.txt",
3100            "    v dir2",
3101            "          modified3.txt",
3102            "          unmodified2.txt",
3103            "      modified4.txt",
3104            "      unmodified3.txt",
3105            "v tree2",
3106            "    > .git",
3107            "    v dir3",
3108            "          modified5.txt",
3109            "          unmodified4.txt",
3110            "      modified6.txt",
3111            "      unmodified5.txt",
3112        ],
3113    );
3114
3115    // Wraps around again to last modified file
3116    panel.update_in(cx, |panel, window, cx| {
3117        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3118    });
3119    cx.run_until_parked();
3120
3121    assert_eq!(
3122        visible_entries_as_strings(&panel, 16..18, cx),
3123        &["      modified6.txt  <== selected", "      unmodified5.txt",],
3124    );
3125
3126    panel.update_in(cx, |panel, window, cx| {
3127        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3128    });
3129    cx.run_until_parked();
3130
3131    assert_eq!(
3132        visible_entries_as_strings(&panel, 13..16, cx),
3133        &[
3134            "    v dir3",
3135            "          modified5.txt  <== selected",
3136            "          unmodified4.txt",
3137        ],
3138    );
3139
3140    panel.update_in(cx, |panel, window, cx| {
3141        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3142    });
3143    cx.run_until_parked();
3144
3145    assert_eq!(
3146        visible_entries_as_strings(&panel, 9..11, cx),
3147        &["      modified4.txt  <== selected", "      unmodified3.txt",],
3148    );
3149
3150    panel.update_in(cx, |panel, window, cx| {
3151        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3152    });
3153    cx.run_until_parked();
3154
3155    assert_eq!(
3156        visible_entries_as_strings(&panel, 6..9, cx),
3157        &[
3158            "    v dir2",
3159            "          modified3.txt  <== selected",
3160            "          unmodified2.txt",
3161        ],
3162    );
3163
3164    panel.update_in(cx, |panel, window, cx| {
3165        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3166    });
3167    cx.run_until_parked();
3168
3169    assert_eq!(
3170        visible_entries_as_strings(&panel, 0..6, cx),
3171        &[
3172            "v tree1",
3173            "    > .git",
3174            "    v dir1",
3175            "          modified1.txt",
3176            "          modified2.txt  <== selected",
3177            "          unmodified1.txt",
3178        ],
3179    );
3180
3181    panel.update_in(cx, |panel, window, cx| {
3182        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3183    });
3184    cx.run_until_parked();
3185
3186    assert_eq!(
3187        visible_entries_as_strings(&panel, 0..6, cx),
3188        &[
3189            "v tree1",
3190            "    > .git",
3191            "    v dir1",
3192            "          modified1.txt  <== selected",
3193            "          modified2.txt",
3194            "          unmodified1.txt",
3195        ],
3196    );
3197}
3198
3199#[gpui::test]
3200async fn test_select_directory(cx: &mut gpui::TestAppContext) {
3201    init_test_with_editor(cx);
3202
3203    let fs = FakeFs::new(cx.executor());
3204    fs.insert_tree(
3205        "/project_root",
3206        json!({
3207            "dir_1": {
3208                "nested_dir": {
3209                    "file_a.py": "# File contents",
3210                }
3211            },
3212            "file_1.py": "# File contents",
3213            "dir_2": {
3214
3215            },
3216            "dir_3": {
3217
3218            },
3219            "file_2.py": "# File contents",
3220            "dir_4": {
3221
3222            },
3223        }),
3224    )
3225    .await;
3226
3227    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3228    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3229    let workspace = window
3230        .read_with(cx, |mw, _| mw.workspace().clone())
3231        .unwrap();
3232    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3233    let panel = workspace.update_in(cx, ProjectPanel::new);
3234    cx.run_until_parked();
3235
3236    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3237    cx.executor().run_until_parked();
3238    select_path(&panel, "project_root/dir_1", cx);
3239    cx.executor().run_until_parked();
3240    assert_eq!(
3241        visible_entries_as_strings(&panel, 0..10, cx),
3242        &[
3243            "v project_root",
3244            "    > dir_1  <== selected",
3245            "    > dir_2",
3246            "    > dir_3",
3247            "    > dir_4",
3248            "      file_1.py",
3249            "      file_2.py",
3250        ]
3251    );
3252    panel.update_in(cx, |panel, window, cx| {
3253        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3254    });
3255
3256    assert_eq!(
3257        visible_entries_as_strings(&panel, 0..10, cx),
3258        &[
3259            "v project_root  <== selected",
3260            "    > dir_1",
3261            "    > dir_2",
3262            "    > dir_3",
3263            "    > dir_4",
3264            "      file_1.py",
3265            "      file_2.py",
3266        ]
3267    );
3268
3269    panel.update_in(cx, |panel, window, cx| {
3270        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3271    });
3272
3273    assert_eq!(
3274        visible_entries_as_strings(&panel, 0..10, cx),
3275        &[
3276            "v project_root",
3277            "    > dir_1",
3278            "    > dir_2",
3279            "    > dir_3",
3280            "    > dir_4  <== selected",
3281            "      file_1.py",
3282            "      file_2.py",
3283        ]
3284    );
3285
3286    panel.update_in(cx, |panel, window, cx| {
3287        panel.select_next_directory(&SelectNextDirectory, window, cx)
3288    });
3289
3290    assert_eq!(
3291        visible_entries_as_strings(&panel, 0..10, cx),
3292        &[
3293            "v project_root  <== selected",
3294            "    > dir_1",
3295            "    > dir_2",
3296            "    > dir_3",
3297            "    > dir_4",
3298            "      file_1.py",
3299            "      file_2.py",
3300        ]
3301    );
3302}
3303
3304#[gpui::test]
3305async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
3306    init_test_with_editor(cx);
3307
3308    let fs = FakeFs::new(cx.executor());
3309    fs.insert_tree(
3310        "/project_root",
3311        json!({
3312            "dir_1": {
3313                "nested_dir": {
3314                    "file_a.py": "# File contents",
3315                }
3316            },
3317            "file_1.py": "# File contents",
3318            "file_2.py": "# File contents",
3319            "zdir_2": {
3320                "nested_dir2": {
3321                    "file_b.py": "# File contents",
3322                }
3323            },
3324        }),
3325    )
3326    .await;
3327
3328    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3329    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3330    let workspace = window
3331        .read_with(cx, |mw, _| mw.workspace().clone())
3332        .unwrap();
3333    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3334    let panel = workspace.update_in(cx, ProjectPanel::new);
3335    cx.run_until_parked();
3336
3337    assert_eq!(
3338        visible_entries_as_strings(&panel, 0..10, cx),
3339        &[
3340            "v project_root",
3341            "    > dir_1",
3342            "    > zdir_2",
3343            "      file_1.py",
3344            "      file_2.py",
3345        ]
3346    );
3347    panel.update_in(cx, |panel, window, cx| {
3348        panel.select_first(&SelectFirst, window, cx)
3349    });
3350
3351    assert_eq!(
3352        visible_entries_as_strings(&panel, 0..10, cx),
3353        &[
3354            "v project_root  <== selected",
3355            "    > dir_1",
3356            "    > zdir_2",
3357            "      file_1.py",
3358            "      file_2.py",
3359        ]
3360    );
3361
3362    panel.update_in(cx, |panel, window, cx| {
3363        panel.select_last(&SelectLast, window, cx)
3364    });
3365
3366    assert_eq!(
3367        visible_entries_as_strings(&panel, 0..10, cx),
3368        &[
3369            "v project_root",
3370            "    > dir_1",
3371            "    > zdir_2",
3372            "      file_1.py",
3373            "      file_2.py  <== selected",
3374        ]
3375    );
3376
3377    cx.update(|_, cx| {
3378        let settings = *ProjectPanelSettings::get_global(cx);
3379        ProjectPanelSettings::override_global(
3380            ProjectPanelSettings {
3381                hide_root: true,
3382                ..settings
3383            },
3384            cx,
3385        );
3386    });
3387
3388    let panel = workspace.update_in(cx, ProjectPanel::new);
3389    cx.run_until_parked();
3390
3391    #[rustfmt::skip]
3392    assert_eq!(
3393        visible_entries_as_strings(&panel, 0..10, cx),
3394        &[
3395            "> dir_1",
3396            "> zdir_2",
3397            "  file_1.py",
3398            "  file_2.py",
3399        ],
3400        "With hide_root=true, root should be hidden"
3401    );
3402
3403    panel.update_in(cx, |panel, window, cx| {
3404        panel.select_first(&SelectFirst, window, cx)
3405    });
3406
3407    assert_eq!(
3408        visible_entries_as_strings(&panel, 0..10, cx),
3409        &[
3410            "> dir_1  <== selected",
3411            "> zdir_2",
3412            "  file_1.py",
3413            "  file_2.py",
3414        ],
3415        "With hide_root=true, first entry should be dir_1, not the hidden root"
3416    );
3417}
3418
3419#[gpui::test]
3420async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3421    init_test_with_editor(cx);
3422
3423    let fs = FakeFs::new(cx.executor());
3424    fs.insert_tree(
3425        "/project_root",
3426        json!({
3427            "dir_1": {
3428                "nested_dir": {
3429                    "file_a.py": "# File contents",
3430                }
3431            },
3432            "file_1.py": "# File contents",
3433        }),
3434    )
3435    .await;
3436
3437    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3438    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3439    let workspace = window
3440        .read_with(cx, |mw, _| mw.workspace().clone())
3441        .unwrap();
3442    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3443    let panel = workspace.update_in(cx, ProjectPanel::new);
3444    cx.run_until_parked();
3445
3446    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3447    cx.executor().run_until_parked();
3448    select_path(&panel, "project_root/dir_1", cx);
3449    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3450    select_path(&panel, "project_root/dir_1/nested_dir", cx);
3451    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3452    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3453    cx.executor().run_until_parked();
3454    assert_eq!(
3455        visible_entries_as_strings(&panel, 0..10, cx),
3456        &[
3457            "v project_root",
3458            "    v dir_1",
3459            "        > nested_dir  <== selected",
3460            "      file_1.py",
3461        ]
3462    );
3463}
3464
3465#[gpui::test]
3466async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3467    init_test_with_editor(cx);
3468
3469    let fs = FakeFs::new(cx.executor());
3470    fs.insert_tree(
3471        "/project_root",
3472        json!({
3473            "dir_1": {
3474                "nested_dir": {
3475                    "file_a.py": "# File contents",
3476                    "file_b.py": "# File contents",
3477                    "file_c.py": "# File contents",
3478                },
3479                "file_1.py": "# File contents",
3480                "file_2.py": "# File contents",
3481                "file_3.py": "# File contents",
3482            },
3483            "dir_2": {
3484                "file_1.py": "# File contents",
3485                "file_2.py": "# File contents",
3486                "file_3.py": "# File contents",
3487            }
3488        }),
3489    )
3490    .await;
3491
3492    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3493    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3494    let workspace = window
3495        .read_with(cx, |mw, _| mw.workspace().clone())
3496        .unwrap();
3497    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3498    let panel = workspace.update_in(cx, ProjectPanel::new);
3499    cx.run_until_parked();
3500
3501    panel.update_in(cx, |panel, window, cx| {
3502        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3503    });
3504    cx.executor().run_until_parked();
3505    assert_eq!(
3506        visible_entries_as_strings(&panel, 0..10, cx),
3507        &["v project_root", "    > dir_1", "    > dir_2",]
3508    );
3509
3510    // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3511    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3512    cx.executor().run_until_parked();
3513    assert_eq!(
3514        visible_entries_as_strings(&panel, 0..10, cx),
3515        &[
3516            "v project_root",
3517            "    v dir_1  <== selected",
3518            "        > nested_dir",
3519            "          file_1.py",
3520            "          file_2.py",
3521            "          file_3.py",
3522            "    > dir_2",
3523        ]
3524    );
3525}
3526
3527#[gpui::test]
3528async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
3529    init_test_with_editor(cx);
3530
3531    let fs = FakeFs::new(cx.executor());
3532    let worktree_content = json!({
3533        "dir_1": {
3534            "file_1.py": "# File contents",
3535        },
3536        "dir_2": {
3537            "file_1.py": "# File contents",
3538        }
3539    });
3540
3541    fs.insert_tree("/project_root_1", worktree_content.clone())
3542        .await;
3543    fs.insert_tree("/project_root_2", worktree_content).await;
3544
3545    let project = Project::test(
3546        fs.clone(),
3547        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
3548        cx,
3549    )
3550    .await;
3551    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3552    let workspace = window
3553        .read_with(cx, |mw, _| mw.workspace().clone())
3554        .unwrap();
3555    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3556    let panel = workspace.update_in(cx, ProjectPanel::new);
3557    cx.run_until_parked();
3558
3559    panel.update_in(cx, |panel, window, cx| {
3560        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3561    });
3562    cx.executor().run_until_parked();
3563    assert_eq!(
3564        visible_entries_as_strings(&panel, 0..10, cx),
3565        &["> project_root_1", "> project_root_2",]
3566    );
3567}
3568
3569#[gpui::test]
3570async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
3571    init_test_with_editor(cx);
3572
3573    let fs = FakeFs::new(cx.executor());
3574    fs.insert_tree(
3575        "/project_root",
3576        json!({
3577            "dir_1": {
3578                "nested_dir": {
3579                    "file_a.py": "# File contents",
3580                    "file_b.py": "# File contents",
3581                    "file_c.py": "# File contents",
3582                },
3583                "file_1.py": "# File contents",
3584                "file_2.py": "# File contents",
3585                "file_3.py": "# File contents",
3586            },
3587            "dir_2": {
3588                "file_1.py": "# File contents",
3589                "file_2.py": "# File contents",
3590                "file_3.py": "# File contents",
3591            }
3592        }),
3593    )
3594    .await;
3595
3596    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3597    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3598    let workspace = window
3599        .read_with(cx, |mw, _| mw.workspace().clone())
3600        .unwrap();
3601    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3602    let panel = workspace.update_in(cx, ProjectPanel::new);
3603    cx.run_until_parked();
3604
3605    // Open project_root/dir_1 to ensure that a nested directory is expanded
3606    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3607    cx.executor().run_until_parked();
3608    assert_eq!(
3609        visible_entries_as_strings(&panel, 0..10, cx),
3610        &[
3611            "v project_root",
3612            "    v dir_1  <== selected",
3613            "        > nested_dir",
3614            "          file_1.py",
3615            "          file_2.py",
3616            "          file_3.py",
3617            "    > dir_2",
3618        ]
3619    );
3620
3621    // Close root directory
3622    toggle_expand_dir(&panel, "project_root", cx);
3623    cx.executor().run_until_parked();
3624    assert_eq!(
3625        visible_entries_as_strings(&panel, 0..10, cx),
3626        &["> project_root  <== selected"]
3627    );
3628
3629    // Run collapse_all_entries and make sure root is not expanded
3630    panel.update_in(cx, |panel, window, cx| {
3631        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3632    });
3633    cx.executor().run_until_parked();
3634    assert_eq!(
3635        visible_entries_as_strings(&panel, 0..10, cx),
3636        &["> project_root  <== selected"]
3637    );
3638}
3639
3640#[gpui::test]
3641async fn test_collapse_all_entries_with_invisible_worktree(cx: &mut gpui::TestAppContext) {
3642    init_test_with_editor(cx);
3643
3644    let fs = FakeFs::new(cx.executor());
3645    fs.insert_tree(
3646        "/project_root",
3647        json!({
3648            "dir_1": {
3649                "nested_dir": {
3650                    "file_a.py": "# File contents",
3651                },
3652                "file_1.py": "# File contents",
3653            },
3654            "dir_2": {
3655                "file_1.py": "# File contents",
3656            }
3657        }),
3658    )
3659    .await;
3660    fs.insert_tree(
3661        "/external",
3662        json!({
3663            "external_file.py": "# External file",
3664        }),
3665    )
3666    .await;
3667
3668    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3669    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3670    let workspace = window
3671        .read_with(cx, |mw, _| mw.workspace().clone())
3672        .unwrap();
3673    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3674    let panel = workspace.update_in(cx, ProjectPanel::new);
3675    cx.run_until_parked();
3676
3677    let (_invisible_worktree, _) = project
3678        .update(cx, |project, cx| {
3679            project.find_or_create_worktree("/external/external_file.py", false, cx)
3680        })
3681        .await
3682        .unwrap();
3683    cx.run_until_parked();
3684
3685    assert_eq!(
3686        visible_entries_as_strings(&panel, 0..10, cx),
3687        &["v project_root", "    > dir_1", "    > dir_2",],
3688        "invisible worktree should not appear in project panel"
3689    );
3690
3691    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3692    cx.executor().run_until_parked();
3693
3694    panel.update_in(cx, |panel, window, cx| {
3695        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3696    });
3697    cx.executor().run_until_parked();
3698    assert_eq!(
3699        visible_entries_as_strings(&panel, 0..10, cx),
3700        &["v project_root", "    > dir_1  <== selected", "    > dir_2",],
3701        "with single visible worktree, root should stay expanded even if invisible worktrees exist"
3702    );
3703}
3704
3705#[gpui::test]
3706async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3707    init_test(cx);
3708
3709    let fs = FakeFs::new(cx.executor());
3710    fs.as_fake().insert_tree(path!("/root"), json!({})).await;
3711    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
3712    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3713    let workspace = window
3714        .read_with(cx, |mw, _| mw.workspace().clone())
3715        .unwrap();
3716    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3717    let panel = workspace.update_in(cx, ProjectPanel::new);
3718    cx.run_until_parked();
3719
3720    // Make a new buffer with no backing file
3721    workspace.update_in(cx, |workspace, window, cx| {
3722        Editor::new_file(workspace, &Default::default(), window, cx)
3723    });
3724
3725    cx.executor().run_until_parked();
3726
3727    // "Save as" the buffer, creating a new backing file for it
3728    let save_task = workspace.update_in(cx, |workspace, window, cx| {
3729        workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3730    });
3731
3732    cx.executor().run_until_parked();
3733    cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
3734    save_task.await.unwrap();
3735
3736    // Rename the file
3737    select_path(&panel, "root/new", cx);
3738    assert_eq!(
3739        visible_entries_as_strings(&panel, 0..10, cx),
3740        &["v root", "      new  <== selected  <== marked"]
3741    );
3742    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3743    panel.update_in(cx, |panel, window, cx| {
3744        panel
3745            .filename_editor
3746            .update(cx, |editor, cx| editor.set_text("newer", window, cx));
3747    });
3748    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3749
3750    cx.executor().run_until_parked();
3751    assert_eq!(
3752        visible_entries_as_strings(&panel, 0..10, cx),
3753        &["v root", "      newer  <== selected"]
3754    );
3755
3756    workspace
3757        .update_in(cx, |workspace, window, cx| {
3758            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3759        })
3760        .await
3761        .unwrap();
3762
3763    cx.executor().run_until_parked();
3764    // assert that saving the file doesn't restore "new"
3765    assert_eq!(
3766        visible_entries_as_strings(&panel, 0..10, cx),
3767        &["v root", "      newer  <== selected"]
3768    );
3769}
3770
3771// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
3772// you can't rename a directory which some program has already open. This is a
3773// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
3774// to it, and thus renaming it will fail on Windows.
3775// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
3776// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
3777#[gpui::test]
3778#[cfg_attr(target_os = "windows", ignore)]
3779async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
3780    init_test_with_editor(cx);
3781
3782    let fs = FakeFs::new(cx.executor());
3783    fs.insert_tree(
3784        "/root1",
3785        json!({
3786            "dir1": {
3787                "file1.txt": "content 1",
3788            },
3789        }),
3790    )
3791    .await;
3792
3793    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3794    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3795    let workspace = window
3796        .read_with(cx, |mw, _| mw.workspace().clone())
3797        .unwrap();
3798    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3799    let panel = workspace.update_in(cx, ProjectPanel::new);
3800    cx.run_until_parked();
3801
3802    toggle_expand_dir(&panel, "root1/dir1", cx);
3803
3804    assert_eq!(
3805        visible_entries_as_strings(&panel, 0..20, cx),
3806        &["v root1", "    v dir1  <== selected", "          file1.txt",],
3807        "Initial state with worktrees"
3808    );
3809
3810    select_path(&panel, "root1", cx);
3811    assert_eq!(
3812        visible_entries_as_strings(&panel, 0..20, cx),
3813        &["v root1  <== selected", "    v dir1", "          file1.txt",],
3814    );
3815
3816    // Rename root1 to new_root1
3817    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3818
3819    assert_eq!(
3820        visible_entries_as_strings(&panel, 0..20, cx),
3821        &[
3822            "v [EDITOR: 'root1']  <== selected",
3823            "    v dir1",
3824            "          file1.txt",
3825        ],
3826    );
3827
3828    let confirm = panel.update_in(cx, |panel, window, cx| {
3829        panel
3830            .filename_editor
3831            .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3832        panel.confirm_edit(true, window, cx).unwrap()
3833    });
3834    confirm.await.unwrap();
3835    cx.run_until_parked();
3836    assert_eq!(
3837        visible_entries_as_strings(&panel, 0..20, cx),
3838        &[
3839            "v new_root1  <== selected",
3840            "    v dir1",
3841            "          file1.txt",
3842        ],
3843        "Should update worktree name"
3844    );
3845
3846    // Ensure internal paths have been updated
3847    select_path(&panel, "new_root1/dir1/file1.txt", cx);
3848    assert_eq!(
3849        visible_entries_as_strings(&panel, 0..20, cx),
3850        &[
3851            "v new_root1",
3852            "    v dir1",
3853            "          file1.txt  <== selected",
3854        ],
3855        "Files in renamed worktree are selectable"
3856    );
3857}
3858
3859#[gpui::test]
3860async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3861    init_test_with_editor(cx);
3862
3863    let fs = FakeFs::new(cx.executor());
3864    fs.insert_tree(
3865        "/root1",
3866        json!({
3867            "dir1": { "file1.txt": "content" },
3868            "file2.txt": "content",
3869        }),
3870    )
3871    .await;
3872    fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3873        .await;
3874
3875    // Test 1: Single worktree, hide_root=true - rename should be blocked
3876    {
3877        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3878        let window =
3879            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3880        let workspace = window
3881            .read_with(cx, |mw, _| mw.workspace().clone())
3882            .unwrap();
3883        let cx = &mut VisualTestContext::from_window(window.into(), cx);
3884
3885        cx.update(|_, cx| {
3886            let settings = *ProjectPanelSettings::get_global(cx);
3887            ProjectPanelSettings::override_global(
3888                ProjectPanelSettings {
3889                    hide_root: true,
3890                    ..settings
3891                },
3892                cx,
3893            );
3894        });
3895
3896        let panel = workspace.update_in(cx, ProjectPanel::new);
3897        cx.run_until_parked();
3898
3899        panel.update(cx, |panel, cx| {
3900            let project = panel.project.read(cx);
3901            let worktree = project.visible_worktrees(cx).next().unwrap();
3902            let root_entry = worktree.read(cx).root_entry().unwrap();
3903            panel.selection = Some(SelectedEntry {
3904                worktree_id: worktree.read(cx).id(),
3905                entry_id: root_entry.id,
3906            });
3907        });
3908
3909        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3910
3911        assert!(
3912            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3913            "Rename should be blocked when hide_root=true with single worktree"
3914        );
3915    }
3916
3917    // Test 2: Multiple worktrees, hide_root=true - rename should work
3918    {
3919        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3920        let window =
3921            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3922        let workspace = window
3923            .read_with(cx, |mw, _| mw.workspace().clone())
3924            .unwrap();
3925        let cx = &mut VisualTestContext::from_window(window.into(), cx);
3926
3927        cx.update(|_, cx| {
3928            let settings = *ProjectPanelSettings::get_global(cx);
3929            ProjectPanelSettings::override_global(
3930                ProjectPanelSettings {
3931                    hide_root: true,
3932                    ..settings
3933                },
3934                cx,
3935            );
3936        });
3937
3938        let panel = workspace.update_in(cx, ProjectPanel::new);
3939        cx.run_until_parked();
3940
3941        select_path(&panel, "root1", cx);
3942        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3943
3944        #[cfg(target_os = "windows")]
3945        assert!(
3946            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3947            "Rename should be blocked on Windows even with multiple worktrees"
3948        );
3949
3950        #[cfg(not(target_os = "windows"))]
3951        {
3952            assert!(
3953                panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
3954                "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3955            );
3956            panel.update_in(cx, |panel, window, cx| {
3957                panel.cancel(&menu::Cancel, window, cx)
3958            });
3959        }
3960    }
3961}
3962
3963#[gpui::test]
3964async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3965    init_test_with_editor(cx);
3966    let fs = FakeFs::new(cx.executor());
3967    fs.insert_tree(
3968        "/project_root",
3969        json!({
3970            "dir_1": {
3971                "nested_dir": {
3972                    "file_a.py": "# File contents",
3973                }
3974            },
3975            "file_1.py": "# File contents",
3976        }),
3977    )
3978    .await;
3979
3980    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3981    let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3982    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3983    let workspace = window
3984        .read_with(cx, |mw, _| mw.workspace().clone())
3985        .unwrap();
3986    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3987    let panel = workspace.update_in(cx, ProjectPanel::new);
3988    cx.run_until_parked();
3989
3990    cx.update(|window, cx| {
3991        panel.update(cx, |this, cx| {
3992            this.select_next(&Default::default(), window, cx);
3993            this.expand_selected_entry(&Default::default(), window, cx);
3994        })
3995    });
3996    cx.run_until_parked();
3997
3998    cx.update(|window, cx| {
3999        panel.update(cx, |this, cx| {
4000            this.expand_selected_entry(&Default::default(), window, cx);
4001        })
4002    });
4003    cx.run_until_parked();
4004
4005    cx.update(|window, cx| {
4006        panel.update(cx, |this, cx| {
4007            this.select_next(&Default::default(), window, cx);
4008            this.expand_selected_entry(&Default::default(), window, cx);
4009        })
4010    });
4011    cx.run_until_parked();
4012
4013    cx.update(|window, cx| {
4014        panel.update(cx, |this, cx| {
4015            this.select_next(&Default::default(), window, cx);
4016        })
4017    });
4018    cx.run_until_parked();
4019
4020    assert_eq!(
4021        visible_entries_as_strings(&panel, 0..10, cx),
4022        &[
4023            "v project_root",
4024            "    v dir_1",
4025            "        v nested_dir",
4026            "              file_a.py  <== selected",
4027            "      file_1.py",
4028        ]
4029    );
4030    let modifiers_with_shift = gpui::Modifiers {
4031        shift: true,
4032        ..Default::default()
4033    };
4034    cx.run_until_parked();
4035    cx.simulate_modifiers_change(modifiers_with_shift);
4036    cx.update(|window, cx| {
4037        panel.update(cx, |this, cx| {
4038            this.select_next(&Default::default(), window, cx);
4039        })
4040    });
4041    assert_eq!(
4042        visible_entries_as_strings(&panel, 0..10, cx),
4043        &[
4044            "v project_root",
4045            "    v dir_1",
4046            "        v nested_dir",
4047            "              file_a.py",
4048            "      file_1.py  <== selected  <== marked",
4049        ]
4050    );
4051    cx.update(|window, cx| {
4052        panel.update(cx, |this, cx| {
4053            this.select_previous(&Default::default(), window, cx);
4054        })
4055    });
4056    assert_eq!(
4057        visible_entries_as_strings(&panel, 0..10, cx),
4058        &[
4059            "v project_root",
4060            "    v dir_1",
4061            "        v nested_dir",
4062            "              file_a.py  <== selected  <== marked",
4063            "      file_1.py  <== marked",
4064        ]
4065    );
4066    cx.update(|window, cx| {
4067        panel.update(cx, |this, cx| {
4068            let drag = DraggedSelection {
4069                active_selection: this.selection.unwrap(),
4070                marked_selections: this.marked_entries.clone().into(),
4071            };
4072            let target_entry = this
4073                .project
4074                .read(cx)
4075                .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
4076                .unwrap();
4077            this.drag_onto(&drag, target_entry.id, false, window, cx);
4078        });
4079    });
4080    cx.run_until_parked();
4081    assert_eq!(
4082        visible_entries_as_strings(&panel, 0..10, cx),
4083        &[
4084            "v project_root",
4085            "    v dir_1",
4086            "        v nested_dir",
4087            "      file_1.py  <== marked",
4088            "      file_a.py  <== selected  <== marked",
4089        ]
4090    );
4091    // ESC clears out all marks
4092    cx.update(|window, cx| {
4093        panel.update(cx, |this, cx| {
4094            this.cancel(&menu::Cancel, window, cx);
4095        })
4096    });
4097    cx.executor().run_until_parked();
4098    assert_eq!(
4099        visible_entries_as_strings(&panel, 0..10, cx),
4100        &[
4101            "v project_root",
4102            "    v dir_1",
4103            "        v nested_dir",
4104            "      file_1.py",
4105            "      file_a.py  <== selected",
4106        ]
4107    );
4108    // ESC clears out all marks
4109    cx.update(|window, cx| {
4110        panel.update(cx, |this, cx| {
4111            this.select_previous(&SelectPrevious, window, cx);
4112            this.select_next(&SelectNext, window, cx);
4113        })
4114    });
4115    assert_eq!(
4116        visible_entries_as_strings(&panel, 0..10, cx),
4117        &[
4118            "v project_root",
4119            "    v dir_1",
4120            "        v nested_dir",
4121            "      file_1.py  <== marked",
4122            "      file_a.py  <== selected  <== marked",
4123        ]
4124    );
4125    cx.simulate_modifiers_change(Default::default());
4126    cx.update(|window, cx| {
4127        panel.update(cx, |this, cx| {
4128            this.cut(&Cut, window, cx);
4129            this.select_previous(&SelectPrevious, window, cx);
4130            this.select_previous(&SelectPrevious, window, cx);
4131
4132            this.paste(&Paste, window, cx);
4133            this.update_visible_entries(None, false, false, window, cx);
4134        })
4135    });
4136    cx.run_until_parked();
4137    assert_eq!(
4138        visible_entries_as_strings(&panel, 0..10, cx),
4139        &[
4140            "v project_root",
4141            "    v dir_1",
4142            "        v nested_dir",
4143            "              file_1.py  <== marked",
4144            "              file_a.py  <== selected  <== marked",
4145        ]
4146    );
4147    cx.simulate_modifiers_change(modifiers_with_shift);
4148    cx.update(|window, cx| {
4149        panel.update(cx, |this, cx| {
4150            this.expand_selected_entry(&Default::default(), window, cx);
4151            this.select_next(&SelectNext, window, cx);
4152            this.select_next(&SelectNext, window, cx);
4153        })
4154    });
4155    submit_deletion(&panel, cx);
4156    assert_eq!(
4157        visible_entries_as_strings(&panel, 0..10, cx),
4158        &[
4159            "v project_root",
4160            "    v dir_1",
4161            "        v nested_dir  <== selected",
4162        ]
4163    );
4164}
4165
4166#[gpui::test]
4167async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
4168    init_test(cx);
4169
4170    let fs = FakeFs::new(cx.executor());
4171    fs.insert_tree(
4172        "/root",
4173        json!({
4174            "a": {
4175                "b": {
4176                    "c": {
4177                        "d": {}
4178                    }
4179                }
4180            },
4181            "target_destination": {}
4182        }),
4183    )
4184    .await;
4185
4186    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4187    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4188    let workspace = window
4189        .read_with(cx, |mw, _| mw.workspace().clone())
4190        .unwrap();
4191    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4192
4193    cx.update(|_, cx| {
4194        let settings = *ProjectPanelSettings::get_global(cx);
4195        ProjectPanelSettings::override_global(
4196            ProjectPanelSettings {
4197                auto_fold_dirs: true,
4198                ..settings
4199            },
4200            cx,
4201        );
4202    });
4203
4204    let panel = workspace.update_in(cx, ProjectPanel::new);
4205    cx.run_until_parked();
4206
4207    // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
4208    select_path(&panel, "root/a/b/c/d", cx);
4209    panel.update_in(cx, |panel, window, cx| {
4210        let drag = DraggedSelection {
4211            active_selection: *panel.selection.as_ref().unwrap(),
4212            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4213        };
4214        let target_entry = panel
4215            .project
4216            .read(cx)
4217            .visible_worktrees(cx)
4218            .next()
4219            .unwrap()
4220            .read(cx)
4221            .entry_for_path(rel_path("target_destination"))
4222            .unwrap();
4223        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4224    });
4225    cx.executor().run_until_parked();
4226
4227    assert_eq!(
4228        visible_entries_as_strings(&panel, 0..10, cx),
4229        &[
4230            "v root",
4231            "    > a/b/c",
4232            "    > target_destination/d  <== selected"
4233        ],
4234        "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
4235    );
4236
4237    // Reset
4238    select_path(&panel, "root/target_destination/d", cx);
4239    panel.update_in(cx, |panel, window, cx| {
4240        let drag = DraggedSelection {
4241            active_selection: *panel.selection.as_ref().unwrap(),
4242            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4243        };
4244        let target_entry = panel
4245            .project
4246            .read(cx)
4247            .visible_worktrees(cx)
4248            .next()
4249            .unwrap()
4250            .read(cx)
4251            .entry_for_path(rel_path("a/b/c"))
4252            .unwrap();
4253        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4254    });
4255    cx.executor().run_until_parked();
4256
4257    // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
4258    select_path(&panel, "root/a/b", cx);
4259    panel.update_in(cx, |panel, window, cx| {
4260        let drag = DraggedSelection {
4261            active_selection: *panel.selection.as_ref().unwrap(),
4262            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4263        };
4264        let target_entry = panel
4265            .project
4266            .read(cx)
4267            .visible_worktrees(cx)
4268            .next()
4269            .unwrap()
4270            .read(cx)
4271            .entry_for_path(rel_path("target_destination"))
4272            .unwrap();
4273        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4274    });
4275    cx.executor().run_until_parked();
4276
4277    assert_eq!(
4278        visible_entries_as_strings(&panel, 0..10, cx),
4279        &["v root", "    v a", "    > target_destination/b/c/d"],
4280        "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
4281    );
4282
4283    // Reset
4284    select_path(&panel, "root/target_destination/b", cx);
4285    panel.update_in(cx, |panel, window, cx| {
4286        let drag = DraggedSelection {
4287            active_selection: *panel.selection.as_ref().unwrap(),
4288            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4289        };
4290        let target_entry = panel
4291            .project
4292            .read(cx)
4293            .visible_worktrees(cx)
4294            .next()
4295            .unwrap()
4296            .read(cx)
4297            .entry_for_path(rel_path("a"))
4298            .unwrap();
4299        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4300    });
4301    cx.executor().run_until_parked();
4302
4303    // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
4304    select_path(&panel, "root/a", cx);
4305    panel.update_in(cx, |panel, window, cx| {
4306        let drag = DraggedSelection {
4307            active_selection: *panel.selection.as_ref().unwrap(),
4308            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4309        };
4310        let target_entry = panel
4311            .project
4312            .read(cx)
4313            .visible_worktrees(cx)
4314            .next()
4315            .unwrap()
4316            .read(cx)
4317            .entry_for_path(rel_path("target_destination"))
4318            .unwrap();
4319        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4320    });
4321    cx.executor().run_until_parked();
4322
4323    assert_eq!(
4324        visible_entries_as_strings(&panel, 0..10, cx),
4325        &["v root", "    > target_destination/a/b/c/d"],
4326        "Moving first directory 'a' should move whole 'a/b/c/d' chain"
4327    );
4328}
4329
4330#[gpui::test]
4331async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) {
4332    init_test(cx);
4333
4334    let fs = FakeFs::new(cx.executor());
4335    fs.insert_tree(
4336        "/root",
4337        json!({
4338            "a": {
4339                "b": {
4340                    "c": {}
4341                }
4342            },
4343            "e": {
4344                "f": {
4345                    "g": {}
4346                }
4347            },
4348            "target": {}
4349        }),
4350    )
4351    .await;
4352
4353    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4354    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4355    let workspace = window
4356        .read_with(cx, |mw, _| mw.workspace().clone())
4357        .unwrap();
4358    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4359
4360    cx.update(|_, cx| {
4361        let settings = *ProjectPanelSettings::get_global(cx);
4362        ProjectPanelSettings::override_global(
4363            ProjectPanelSettings {
4364                auto_fold_dirs: true,
4365                ..settings
4366            },
4367            cx,
4368        );
4369    });
4370
4371    let panel = workspace.update_in(cx, ProjectPanel::new);
4372    cx.run_until_parked();
4373
4374    assert_eq!(
4375        visible_entries_as_strings(&panel, 0..10, cx),
4376        &["v root", "    > a/b/c", "    > e/f/g", "    > target"]
4377    );
4378
4379    select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx);
4380    select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx);
4381
4382    panel.update_in(cx, |panel, window, cx| {
4383        let drag = DraggedSelection {
4384            active_selection: *panel.selection.as_ref().unwrap(),
4385            marked_selections: panel.marked_entries.clone().into(),
4386        };
4387        let target_entry = panel
4388            .project
4389            .read(cx)
4390            .visible_worktrees(cx)
4391            .next()
4392            .unwrap()
4393            .read(cx)
4394            .entry_for_path(rel_path("target"))
4395            .unwrap();
4396        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4397    });
4398    cx.executor().run_until_parked();
4399
4400    // After dragging 'b/c' and 'f/g' should be moved to target
4401    assert_eq!(
4402        visible_entries_as_strings(&panel, 0..10, cx),
4403        &[
4404            "v root",
4405            "    > a",
4406            "    > e",
4407            "    v target",
4408            "        > b/c",
4409            "        > f/g  <== selected  <== marked"
4410        ],
4411        "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'"
4412    );
4413}
4414
4415#[gpui::test]
4416async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4417    init_test(cx);
4418
4419    let fs = FakeFs::new(cx.executor());
4420    fs.insert_tree(
4421        "/root_a",
4422        json!({
4423            "src": {
4424                "lib.rs": "",
4425                "main.rs": ""
4426            },
4427            "docs": {
4428                "guide.md": ""
4429            },
4430            "multi": {
4431                "alpha.txt": "",
4432                "beta.txt": ""
4433            }
4434        }),
4435    )
4436    .await;
4437    fs.insert_tree(
4438        "/root_b",
4439        json!({
4440            "dst": {
4441                "existing.md": ""
4442            },
4443            "target.txt": ""
4444        }),
4445    )
4446    .await;
4447
4448    let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4449    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4450    let workspace = window
4451        .read_with(cx, |mw, _| mw.workspace().clone())
4452        .unwrap();
4453    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4454    let panel = workspace.update_in(cx, ProjectPanel::new);
4455    cx.run_until_parked();
4456
4457    // Case 1: move a file onto a directory in another worktree.
4458    select_path(&panel, "root_a/src/main.rs", cx);
4459    drag_selection_to(&panel, "root_b/dst", false, cx);
4460    assert!(
4461        find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4462        "Dragged file should appear under destination worktree"
4463    );
4464    assert_eq!(
4465        find_project_entry(&panel, "root_a/src/main.rs", cx),
4466        None,
4467        "Dragged file should be removed from the source worktree"
4468    );
4469
4470    // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4471    select_path(&panel, "root_a/docs/guide.md", cx);
4472    drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4473    assert!(
4474        find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4475        "Dropping onto a file should place the entry beside the target file"
4476    );
4477    assert_eq!(
4478        find_project_entry(&panel, "root_a/docs/guide.md", cx),
4479        None,
4480        "Source file should be removed after the move"
4481    );
4482
4483    // Case 3: move an entire directory.
4484    select_path(&panel, "root_a/src", cx);
4485    drag_selection_to(&panel, "root_b/dst", false, cx);
4486    assert!(
4487        find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4488        "Dragging a directory should move its nested contents"
4489    );
4490    assert_eq!(
4491        find_project_entry(&panel, "root_a/src", cx),
4492        None,
4493        "Directory should no longer exist in the source worktree"
4494    );
4495
4496    // Case 4: multi-selection drag between worktrees.
4497    panel.update(cx, |panel, _| panel.marked_entries.clear());
4498    select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4499    select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4500    drag_selection_to(&panel, "root_b/dst", false, cx);
4501    assert!(
4502        find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4503            && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4504        "All marked entries should move to the destination worktree"
4505    );
4506    assert_eq!(
4507        find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4508        None,
4509        "Marked entries should be removed from the origin worktree"
4510    );
4511    assert_eq!(
4512        find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4513        None,
4514        "Marked entries should be removed from the origin worktree"
4515    );
4516}
4517
4518#[gpui::test]
4519async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4520    init_test(cx);
4521
4522    let fs = FakeFs::new(cx.executor());
4523    fs.insert_tree(
4524        "/root",
4525        json!({
4526            "src": {
4527                "folder1": {
4528                    "mod.rs": "// folder1 mod"
4529                },
4530                "folder2": {
4531                    "mod.rs": "// folder2 mod"
4532                },
4533                "folder3": {
4534                    "mod.rs": "// folder3 mod",
4535                    "helper.rs": "// helper"
4536                },
4537                "main.rs": ""
4538            }
4539        }),
4540    )
4541    .await;
4542
4543    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4544    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4545    let workspace = window
4546        .read_with(cx, |mw, _| mw.workspace().clone())
4547        .unwrap();
4548    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4549    let panel = workspace.update_in(cx, ProjectPanel::new);
4550    cx.run_until_parked();
4551
4552    toggle_expand_dir(&panel, "root/src", cx);
4553    toggle_expand_dir(&panel, "root/src/folder1", cx);
4554    toggle_expand_dir(&panel, "root/src/folder2", cx);
4555    toggle_expand_dir(&panel, "root/src/folder3", cx);
4556    cx.run_until_parked();
4557
4558    // Case 1: Dragging a folder and a file from a sibling folder together.
4559    panel.update(cx, |panel, _| panel.marked_entries.clear());
4560    select_path_with_mark(&panel, "root/src/folder1", cx);
4561    select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4562
4563    drag_selection_to(&panel, "root", false, cx);
4564
4565    assert!(
4566        find_project_entry(&panel, "root/folder1", cx).is_some(),
4567        "folder1 should be at root after drag"
4568    );
4569    assert!(
4570        find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4571        "folder1/mod.rs should still be inside folder1 after drag"
4572    );
4573    assert_eq!(
4574        find_project_entry(&panel, "root/src/folder1", cx),
4575        None,
4576        "folder1 should no longer be in src"
4577    );
4578    assert!(
4579        find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4580        "mod.rs from folder2 should be at root"
4581    );
4582
4583    // Case 2: Dragging a folder and its own child together.
4584    panel.update(cx, |panel, _| panel.marked_entries.clear());
4585    select_path_with_mark(&panel, "root/src/folder3", cx);
4586    select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4587
4588    drag_selection_to(&panel, "root", false, cx);
4589
4590    assert!(
4591        find_project_entry(&panel, "root/folder3", cx).is_some(),
4592        "folder3 should be at root after drag"
4593    );
4594    assert!(
4595        find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4596        "folder3/mod.rs should still be inside folder3"
4597    );
4598    assert!(
4599        find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4600        "folder3/helper.rs should still be inside folder3"
4601    );
4602}
4603
4604#[gpui::test]
4605async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4606    init_test_with_editor(cx);
4607    cx.update(|cx| {
4608        cx.update_global::<SettingsStore, _>(|store, cx| {
4609            store.update_user_settings(cx, |settings| {
4610                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4611                settings
4612                    .project_panel
4613                    .get_or_insert_default()
4614                    .auto_reveal_entries = Some(false);
4615            });
4616        })
4617    });
4618
4619    let fs = FakeFs::new(cx.background_executor.clone());
4620    fs.insert_tree(
4621        "/project_root",
4622        json!({
4623            ".git": {},
4624            ".gitignore": "**/gitignored_dir",
4625            "dir_1": {
4626                "file_1.py": "# File 1_1 contents",
4627                "file_2.py": "# File 1_2 contents",
4628                "file_3.py": "# File 1_3 contents",
4629                "gitignored_dir": {
4630                    "file_a.py": "# File contents",
4631                    "file_b.py": "# File contents",
4632                    "file_c.py": "# File contents",
4633                },
4634            },
4635            "dir_2": {
4636                "file_1.py": "# File 2_1 contents",
4637                "file_2.py": "# File 2_2 contents",
4638                "file_3.py": "# File 2_3 contents",
4639            }
4640        }),
4641    )
4642    .await;
4643
4644    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4645    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4646    let workspace = window
4647        .read_with(cx, |mw, _| mw.workspace().clone())
4648        .unwrap();
4649    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4650    let panel = workspace.update_in(cx, ProjectPanel::new);
4651    cx.run_until_parked();
4652
4653    assert_eq!(
4654        visible_entries_as_strings(&panel, 0..20, cx),
4655        &[
4656            "v project_root",
4657            "    > .git",
4658            "    > dir_1",
4659            "    > dir_2",
4660            "      .gitignore",
4661        ]
4662    );
4663
4664    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4665        .expect("dir 1 file is not ignored and should have an entry");
4666    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4667        .expect("dir 2 file is not ignored and should have an entry");
4668    let gitignored_dir_file =
4669        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4670    assert_eq!(
4671        gitignored_dir_file, None,
4672        "File in the gitignored dir should not have an entry before its dir is toggled"
4673    );
4674
4675    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4676    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4677    cx.executor().run_until_parked();
4678    assert_eq!(
4679        visible_entries_as_strings(&panel, 0..20, cx),
4680        &[
4681            "v project_root",
4682            "    > .git",
4683            "    v dir_1",
4684            "        v gitignored_dir  <== selected",
4685            "              file_a.py",
4686            "              file_b.py",
4687            "              file_c.py",
4688            "          file_1.py",
4689            "          file_2.py",
4690            "          file_3.py",
4691            "    > dir_2",
4692            "      .gitignore",
4693        ],
4694        "Should show gitignored dir file list in the project panel"
4695    );
4696    let gitignored_dir_file =
4697        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4698            .expect("after gitignored dir got opened, a file entry should be present");
4699
4700    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4701    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4702    assert_eq!(
4703        visible_entries_as_strings(&panel, 0..20, cx),
4704        &[
4705            "v project_root",
4706            "    > .git",
4707            "    > dir_1  <== selected",
4708            "    > dir_2",
4709            "      .gitignore",
4710        ],
4711        "Should hide all dir contents again and prepare for the auto reveal test"
4712    );
4713
4714    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4715        panel.update(cx, |panel, cx| {
4716            panel.project.update(cx, |_, cx| {
4717                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4718            })
4719        });
4720        cx.run_until_parked();
4721        assert_eq!(
4722            visible_entries_as_strings(&panel, 0..20, cx),
4723            &[
4724                "v project_root",
4725                "    > .git",
4726                "    > dir_1  <== selected",
4727                "    > dir_2",
4728                "      .gitignore",
4729            ],
4730            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4731        );
4732    }
4733
4734    cx.update(|_, cx| {
4735        cx.update_global::<SettingsStore, _>(|store, cx| {
4736            store.update_user_settings(cx, |settings| {
4737                settings
4738                    .project_panel
4739                    .get_or_insert_default()
4740                    .auto_reveal_entries = Some(true)
4741            });
4742        })
4743    });
4744
4745    panel.update(cx, |panel, cx| {
4746        panel.project.update(cx, |_, cx| {
4747            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4748        })
4749    });
4750    cx.run_until_parked();
4751    assert_eq!(
4752        visible_entries_as_strings(&panel, 0..20, cx),
4753        &[
4754            "v project_root",
4755            "    > .git",
4756            "    v dir_1",
4757            "        > gitignored_dir",
4758            "          file_1.py  <== selected  <== marked",
4759            "          file_2.py",
4760            "          file_3.py",
4761            "    > dir_2",
4762            "      .gitignore",
4763        ],
4764        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4765    );
4766
4767    panel.update(cx, |panel, cx| {
4768        panel.project.update(cx, |_, cx| {
4769            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4770        })
4771    });
4772    cx.run_until_parked();
4773    assert_eq!(
4774        visible_entries_as_strings(&panel, 0..20, cx),
4775        &[
4776            "v project_root",
4777            "    > .git",
4778            "    v dir_1",
4779            "        > gitignored_dir",
4780            "          file_1.py",
4781            "          file_2.py",
4782            "          file_3.py",
4783            "    v dir_2",
4784            "          file_1.py  <== selected  <== marked",
4785            "          file_2.py",
4786            "          file_3.py",
4787            "      .gitignore",
4788        ],
4789        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4790    );
4791
4792    panel.update(cx, |panel, cx| {
4793        panel.project.update(cx, |_, cx| {
4794            cx.emit(project::Event::ActiveEntryChanged(Some(
4795                gitignored_dir_file,
4796            )))
4797        })
4798    });
4799    cx.run_until_parked();
4800    assert_eq!(
4801        visible_entries_as_strings(&panel, 0..20, cx),
4802        &[
4803            "v project_root",
4804            "    > .git",
4805            "    v dir_1",
4806            "        > gitignored_dir",
4807            "          file_1.py",
4808            "          file_2.py",
4809            "          file_3.py",
4810            "    v dir_2",
4811            "          file_1.py  <== selected  <== marked",
4812            "          file_2.py",
4813            "          file_3.py",
4814            "      .gitignore",
4815        ],
4816        "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4817    );
4818
4819    panel.update(cx, |panel, cx| {
4820        panel.project.update(cx, |_, cx| {
4821            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4822        })
4823    });
4824    cx.run_until_parked();
4825    assert_eq!(
4826        visible_entries_as_strings(&panel, 0..20, cx),
4827        &[
4828            "v project_root",
4829            "    > .git",
4830            "    v dir_1",
4831            "        v gitignored_dir",
4832            "              file_a.py  <== selected  <== marked",
4833            "              file_b.py",
4834            "              file_c.py",
4835            "          file_1.py",
4836            "          file_2.py",
4837            "          file_3.py",
4838            "    v dir_2",
4839            "          file_1.py",
4840            "          file_2.py",
4841            "          file_3.py",
4842            "      .gitignore",
4843        ],
4844        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4845    );
4846}
4847
4848#[gpui::test]
4849async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
4850    init_test_with_editor(cx);
4851    cx.update(|cx| {
4852        cx.update_global::<SettingsStore, _>(|store, cx| {
4853            store.update_user_settings(cx, |settings| {
4854                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4855                settings.project.worktree.file_scan_inclusions =
4856                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
4857                settings
4858                    .project_panel
4859                    .get_or_insert_default()
4860                    .auto_reveal_entries = Some(false)
4861            });
4862        })
4863    });
4864
4865    let fs = FakeFs::new(cx.background_executor.clone());
4866    fs.insert_tree(
4867        "/project_root",
4868        json!({
4869            ".git": {},
4870            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
4871            "dir_1": {
4872                "file_1.py": "# File 1_1 contents",
4873                "file_2.py": "# File 1_2 contents",
4874                "file_3.py": "# File 1_3 contents",
4875                "gitignored_dir": {
4876                    "file_a.py": "# File contents",
4877                    "file_b.py": "# File contents",
4878                    "file_c.py": "# File contents",
4879                },
4880            },
4881            "dir_2": {
4882                "file_1.py": "# File 2_1 contents",
4883                "file_2.py": "# File 2_2 contents",
4884                "file_3.py": "# File 2_3 contents",
4885            },
4886            "always_included_but_ignored_dir": {
4887                "file_a.py": "# File contents",
4888                "file_b.py": "# File contents",
4889                "file_c.py": "# File contents",
4890            },
4891        }),
4892    )
4893    .await;
4894
4895    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4896    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4897    let workspace = window
4898        .read_with(cx, |mw, _| mw.workspace().clone())
4899        .unwrap();
4900    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4901    let panel = workspace.update_in(cx, ProjectPanel::new);
4902    cx.run_until_parked();
4903
4904    assert_eq!(
4905        visible_entries_as_strings(&panel, 0..20, cx),
4906        &[
4907            "v project_root",
4908            "    > .git",
4909            "    > always_included_but_ignored_dir",
4910            "    > dir_1",
4911            "    > dir_2",
4912            "      .gitignore",
4913        ]
4914    );
4915
4916    let gitignored_dir_file =
4917        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4918    let always_included_but_ignored_dir_file = find_project_entry(
4919        &panel,
4920        "project_root/always_included_but_ignored_dir/file_a.py",
4921        cx,
4922    )
4923    .expect("file that is .gitignored but set to always be included should have an entry");
4924    assert_eq!(
4925        gitignored_dir_file, None,
4926        "File in the gitignored dir should not have an entry unless its directory is toggled"
4927    );
4928
4929    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4930    cx.run_until_parked();
4931    cx.update(|_, cx| {
4932        cx.update_global::<SettingsStore, _>(|store, cx| {
4933            store.update_user_settings(cx, |settings| {
4934                settings
4935                    .project_panel
4936                    .get_or_insert_default()
4937                    .auto_reveal_entries = Some(true)
4938            });
4939        })
4940    });
4941
4942    panel.update(cx, |panel, cx| {
4943        panel.project.update(cx, |_, cx| {
4944            cx.emit(project::Event::ActiveEntryChanged(Some(
4945                always_included_but_ignored_dir_file,
4946            )))
4947        })
4948    });
4949    cx.run_until_parked();
4950
4951    assert_eq!(
4952        visible_entries_as_strings(&panel, 0..20, cx),
4953        &[
4954            "v project_root",
4955            "    > .git",
4956            "    v always_included_but_ignored_dir",
4957            "          file_a.py  <== selected  <== marked",
4958            "          file_b.py",
4959            "          file_c.py",
4960            "    v dir_1",
4961            "        > gitignored_dir",
4962            "          file_1.py",
4963            "          file_2.py",
4964            "          file_3.py",
4965            "    > dir_2",
4966            "      .gitignore",
4967        ],
4968        "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
4969    );
4970}
4971
4972#[gpui::test]
4973async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4974    init_test_with_editor(cx);
4975    cx.update(|cx| {
4976        cx.update_global::<SettingsStore, _>(|store, cx| {
4977            store.update_user_settings(cx, |settings| {
4978                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4979                settings
4980                    .project_panel
4981                    .get_or_insert_default()
4982                    .auto_reveal_entries = Some(false)
4983            });
4984        })
4985    });
4986
4987    let fs = FakeFs::new(cx.background_executor.clone());
4988    fs.insert_tree(
4989        "/project_root",
4990        json!({
4991            ".git": {},
4992            ".gitignore": "**/gitignored_dir",
4993            "dir_1": {
4994                "file_1.py": "# File 1_1 contents",
4995                "file_2.py": "# File 1_2 contents",
4996                "file_3.py": "# File 1_3 contents",
4997                "gitignored_dir": {
4998                    "file_a.py": "# File contents",
4999                    "file_b.py": "# File contents",
5000                    "file_c.py": "# File contents",
5001                },
5002            },
5003            "dir_2": {
5004                "file_1.py": "# File 2_1 contents",
5005                "file_2.py": "# File 2_2 contents",
5006                "file_3.py": "# File 2_3 contents",
5007            }
5008        }),
5009    )
5010    .await;
5011
5012    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5013    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5014    let workspace = window
5015        .read_with(cx, |mw, _| mw.workspace().clone())
5016        .unwrap();
5017    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5018    let panel = workspace.update_in(cx, ProjectPanel::new);
5019    cx.run_until_parked();
5020
5021    assert_eq!(
5022        visible_entries_as_strings(&panel, 0..20, cx),
5023        &[
5024            "v project_root",
5025            "    > .git",
5026            "    > dir_1",
5027            "    > dir_2",
5028            "      .gitignore",
5029        ]
5030    );
5031
5032    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5033        .expect("dir 1 file is not ignored and should have an entry");
5034    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5035        .expect("dir 2 file is not ignored and should have an entry");
5036    let gitignored_dir_file =
5037        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5038    assert_eq!(
5039        gitignored_dir_file, None,
5040        "File in the gitignored dir should not have an entry before its dir is toggled"
5041    );
5042
5043    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5044    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5045    cx.run_until_parked();
5046    assert_eq!(
5047        visible_entries_as_strings(&panel, 0..20, cx),
5048        &[
5049            "v project_root",
5050            "    > .git",
5051            "    v dir_1",
5052            "        v gitignored_dir  <== selected",
5053            "              file_a.py",
5054            "              file_b.py",
5055            "              file_c.py",
5056            "          file_1.py",
5057            "          file_2.py",
5058            "          file_3.py",
5059            "    > dir_2",
5060            "      .gitignore",
5061        ],
5062        "Should show gitignored dir file list in the project panel"
5063    );
5064    let gitignored_dir_file =
5065        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5066            .expect("after gitignored dir got opened, a file entry should be present");
5067
5068    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5069    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5070    assert_eq!(
5071        visible_entries_as_strings(&panel, 0..20, cx),
5072        &[
5073            "v project_root",
5074            "    > .git",
5075            "    > dir_1  <== selected",
5076            "    > dir_2",
5077            "      .gitignore",
5078        ],
5079        "Should hide all dir contents again and prepare for the explicit reveal test"
5080    );
5081
5082    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5083        panel.update(cx, |panel, cx| {
5084            panel.project.update(cx, |_, cx| {
5085                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5086            })
5087        });
5088        cx.run_until_parked();
5089        assert_eq!(
5090            visible_entries_as_strings(&panel, 0..20, cx),
5091            &[
5092                "v project_root",
5093                "    > .git",
5094                "    > dir_1  <== selected",
5095                "    > dir_2",
5096                "      .gitignore",
5097            ],
5098            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5099        );
5100    }
5101
5102    panel.update(cx, |panel, cx| {
5103        panel.project.update(cx, |_, cx| {
5104            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5105        })
5106    });
5107    cx.run_until_parked();
5108    assert_eq!(
5109        visible_entries_as_strings(&panel, 0..20, cx),
5110        &[
5111            "v project_root",
5112            "    > .git",
5113            "    v dir_1",
5114            "        > gitignored_dir",
5115            "          file_1.py  <== selected  <== marked",
5116            "          file_2.py",
5117            "          file_3.py",
5118            "    > dir_2",
5119            "      .gitignore",
5120        ],
5121        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5122    );
5123
5124    panel.update(cx, |panel, cx| {
5125        panel.project.update(cx, |_, cx| {
5126            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5127        })
5128    });
5129    cx.run_until_parked();
5130    assert_eq!(
5131        visible_entries_as_strings(&panel, 0..20, cx),
5132        &[
5133            "v project_root",
5134            "    > .git",
5135            "    v dir_1",
5136            "        > gitignored_dir",
5137            "          file_1.py",
5138            "          file_2.py",
5139            "          file_3.py",
5140            "    v dir_2",
5141            "          file_1.py  <== selected  <== marked",
5142            "          file_2.py",
5143            "          file_3.py",
5144            "      .gitignore",
5145        ],
5146        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5147    );
5148
5149    panel.update(cx, |panel, cx| {
5150        panel.project.update(cx, |_, cx| {
5151            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5152        })
5153    });
5154    cx.run_until_parked();
5155    assert_eq!(
5156        visible_entries_as_strings(&panel, 0..20, cx),
5157        &[
5158            "v project_root",
5159            "    > .git",
5160            "    v dir_1",
5161            "        v gitignored_dir",
5162            "              file_a.py  <== selected  <== marked",
5163            "              file_b.py",
5164            "              file_c.py",
5165            "          file_1.py",
5166            "          file_2.py",
5167            "          file_3.py",
5168            "    v dir_2",
5169            "          file_1.py",
5170            "          file_2.py",
5171            "          file_3.py",
5172            "      .gitignore",
5173        ],
5174        "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5175    );
5176}
5177
5178#[gpui::test]
5179async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5180    init_test(cx);
5181    cx.update(|cx| {
5182        cx.update_global::<SettingsStore, _>(|store, cx| {
5183            store.update_user_settings(cx, |settings| {
5184                settings.project.worktree.file_scan_exclusions =
5185                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5186            });
5187        });
5188    });
5189
5190    cx.update(|cx| {
5191        register_project_item::<TestProjectItemView>(cx);
5192    });
5193
5194    let fs = FakeFs::new(cx.executor());
5195    fs.insert_tree(
5196        "/root1",
5197        json!({
5198            ".dockerignore": "",
5199            ".git": {
5200                "HEAD": "",
5201            },
5202        }),
5203    )
5204    .await;
5205
5206    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5207    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5208    let workspace = window
5209        .read_with(cx, |mw, _| mw.workspace().clone())
5210        .unwrap();
5211    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5212    let panel = workspace.update_in(cx, |workspace, window, cx| {
5213        let panel = ProjectPanel::new(workspace, window, cx);
5214        workspace.add_panel(panel.clone(), window, cx);
5215        panel
5216    });
5217    cx.run_until_parked();
5218
5219    select_path(&panel, "root1", cx);
5220    assert_eq!(
5221        visible_entries_as_strings(&panel, 0..10, cx),
5222        &["v root1  <== selected", "      .dockerignore",]
5223    );
5224    workspace.update_in(cx, |workspace, _, cx| {
5225        assert!(
5226            workspace.active_item(cx).is_none(),
5227            "Should have no active items in the beginning"
5228        );
5229    });
5230
5231    let excluded_file_path = ".git/COMMIT_EDITMSG";
5232    let excluded_dir_path = "excluded_dir";
5233
5234    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5235    cx.run_until_parked();
5236    panel.update_in(cx, |panel, window, cx| {
5237        assert!(panel.filename_editor.read(cx).is_focused(window));
5238    });
5239    panel
5240        .update_in(cx, |panel, window, cx| {
5241            panel.filename_editor.update(cx, |editor, cx| {
5242                editor.set_text(excluded_file_path, window, cx)
5243            });
5244            panel.confirm_edit(true, window, cx).unwrap()
5245        })
5246        .await
5247        .unwrap();
5248
5249    assert_eq!(
5250        visible_entries_as_strings(&panel, 0..13, cx),
5251        &["v root1", "      .dockerignore"],
5252        "Excluded dir should not be shown after opening a file in it"
5253    );
5254    panel.update_in(cx, |panel, window, cx| {
5255        assert!(
5256            !panel.filename_editor.read(cx).is_focused(window),
5257            "Should have closed the file name editor"
5258        );
5259    });
5260    workspace.update_in(cx, |workspace, _, cx| {
5261        let active_entry_path = workspace
5262            .active_item(cx)
5263            .expect("should have opened and activated the excluded item")
5264            .act_as::<TestProjectItemView>(cx)
5265            .expect("should have opened the corresponding project item for the excluded item")
5266            .read(cx)
5267            .path
5268            .clone();
5269        assert_eq!(
5270            active_entry_path.path.as_ref(),
5271            rel_path(excluded_file_path),
5272            "Should open the excluded file"
5273        );
5274
5275        assert!(
5276            workspace.notification_ids().is_empty(),
5277            "Should have no notifications after opening an excluded file"
5278        );
5279    });
5280    assert!(
5281        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5282        "Should have created the excluded file"
5283    );
5284
5285    select_path(&panel, "root1", cx);
5286    panel.update_in(cx, |panel, window, cx| {
5287        panel.new_directory(&NewDirectory, window, cx)
5288    });
5289    cx.run_until_parked();
5290    panel.update_in(cx, |panel, window, cx| {
5291        assert!(panel.filename_editor.read(cx).is_focused(window));
5292    });
5293    panel
5294        .update_in(cx, |panel, window, cx| {
5295            panel.filename_editor.update(cx, |editor, cx| {
5296                editor.set_text(excluded_file_path, window, cx)
5297            });
5298            panel.confirm_edit(true, window, cx).unwrap()
5299        })
5300        .await
5301        .unwrap();
5302    cx.run_until_parked();
5303    assert_eq!(
5304        visible_entries_as_strings(&panel, 0..13, cx),
5305        &["v root1", "      .dockerignore"],
5306        "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5307    );
5308    panel.update_in(cx, |panel, window, cx| {
5309        assert!(
5310            !panel.filename_editor.read(cx).is_focused(window),
5311            "Should have closed the file name editor"
5312        );
5313    });
5314    workspace.update_in(cx, |workspace, _, cx| {
5315        let notifications = workspace.notification_ids();
5316        assert_eq!(
5317            notifications.len(),
5318            1,
5319            "Should receive one notification with the error message"
5320        );
5321        workspace.dismiss_notification(notifications.first().unwrap(), cx);
5322        assert!(workspace.notification_ids().is_empty());
5323    });
5324
5325    select_path(&panel, "root1", cx);
5326    panel.update_in(cx, |panel, window, cx| {
5327        panel.new_directory(&NewDirectory, window, cx)
5328    });
5329    cx.run_until_parked();
5330
5331    panel.update_in(cx, |panel, window, cx| {
5332        assert!(panel.filename_editor.read(cx).is_focused(window));
5333    });
5334
5335    panel
5336        .update_in(cx, |panel, window, cx| {
5337            panel.filename_editor.update(cx, |editor, cx| {
5338                editor.set_text(excluded_dir_path, window, cx)
5339            });
5340            panel.confirm_edit(true, window, cx).unwrap()
5341        })
5342        .await
5343        .unwrap();
5344
5345    cx.run_until_parked();
5346
5347    assert_eq!(
5348        visible_entries_as_strings(&panel, 0..13, cx),
5349        &["v root1", "      .dockerignore"],
5350        "Should not change the project panel after trying to create an excluded directory"
5351    );
5352    panel.update_in(cx, |panel, window, cx| {
5353        assert!(
5354            !panel.filename_editor.read(cx).is_focused(window),
5355            "Should have closed the file name editor"
5356        );
5357    });
5358    workspace.update_in(cx, |workspace, _, cx| {
5359        let notifications = workspace.notification_ids();
5360        assert_eq!(
5361            notifications.len(),
5362            1,
5363            "Should receive one notification explaining that no directory is actually shown"
5364        );
5365        workspace.dismiss_notification(notifications.first().unwrap(), cx);
5366        assert!(workspace.notification_ids().is_empty());
5367    });
5368    assert!(
5369        fs.is_dir(Path::new("/root1/excluded_dir")).await,
5370        "Should have created the excluded directory"
5371    );
5372}
5373
5374#[gpui::test]
5375async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5376    init_test_with_editor(cx);
5377
5378    let fs = FakeFs::new(cx.executor());
5379    fs.insert_tree(
5380        "/src",
5381        json!({
5382            "test": {
5383                "first.rs": "// First Rust file",
5384                "second.rs": "// Second Rust file",
5385                "third.rs": "// Third Rust file",
5386            }
5387        }),
5388    )
5389    .await;
5390
5391    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5392    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5393    let workspace = window
5394        .read_with(cx, |mw, _| mw.workspace().clone())
5395        .unwrap();
5396    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5397    let panel = workspace.update_in(cx, |workspace, window, cx| {
5398        let panel = ProjectPanel::new(workspace, window, cx);
5399        workspace.add_panel(panel.clone(), window, cx);
5400        panel
5401    });
5402    cx.run_until_parked();
5403
5404    select_path(&panel, "src", cx);
5405    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5406    cx.executor().run_until_parked();
5407    assert_eq!(
5408        visible_entries_as_strings(&panel, 0..10, cx),
5409        &[
5410            //
5411            "v src  <== selected",
5412            "    > test"
5413        ]
5414    );
5415    panel.update_in(cx, |panel, window, cx| {
5416        panel.new_directory(&NewDirectory, window, cx)
5417    });
5418    cx.executor().run_until_parked();
5419    panel.update_in(cx, |panel, window, cx| {
5420        assert!(panel.filename_editor.read(cx).is_focused(window));
5421    });
5422    assert_eq!(
5423        visible_entries_as_strings(&panel, 0..10, cx),
5424        &[
5425            //
5426            "v src",
5427            "    > [EDITOR: '']  <== selected",
5428            "    > test"
5429        ]
5430    );
5431
5432    panel.update_in(cx, |panel, window, cx| {
5433        panel.cancel(&menu::Cancel, window, cx);
5434    });
5435    cx.executor().run_until_parked();
5436    assert_eq!(
5437        visible_entries_as_strings(&panel, 0..10, cx),
5438        &[
5439            //
5440            "v src  <== selected",
5441            "    > test"
5442        ]
5443    );
5444
5445    panel.update_in(cx, |panel, window, cx| {
5446        panel.new_directory(&NewDirectory, window, cx)
5447    });
5448    cx.executor().run_until_parked();
5449    panel.update_in(cx, |panel, window, cx| {
5450        assert!(panel.filename_editor.read(cx).is_focused(window));
5451    });
5452    assert_eq!(
5453        visible_entries_as_strings(&panel, 0..10, cx),
5454        &[
5455            //
5456            "v src",
5457            "    > [EDITOR: '']  <== selected",
5458            "    > test"
5459        ]
5460    );
5461    workspace.update_in(cx, |_, window, _| window.blur());
5462    cx.executor().run_until_parked();
5463    assert_eq!(
5464        visible_entries_as_strings(&panel, 0..10, cx),
5465        &[
5466            //
5467            "v src  <== selected",
5468            "    > test"
5469        ]
5470    );
5471}
5472
5473#[gpui::test]
5474async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5475    init_test_with_editor(cx);
5476
5477    let fs = FakeFs::new(cx.executor());
5478    fs.insert_tree(
5479        "/root",
5480        json!({
5481            "dir1": {
5482                "subdir1": {},
5483                "file1.txt": "",
5484                "file2.txt": "",
5485            },
5486            "dir2": {
5487                "subdir2": {},
5488                "file3.txt": "",
5489                "file4.txt": "",
5490            },
5491            "file5.txt": "",
5492            "file6.txt": "",
5493        }),
5494    )
5495    .await;
5496
5497    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5498    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5499    let workspace = window
5500        .read_with(cx, |mw, _| mw.workspace().clone())
5501        .unwrap();
5502    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5503    let panel = workspace.update_in(cx, ProjectPanel::new);
5504    cx.run_until_parked();
5505
5506    toggle_expand_dir(&panel, "root/dir1", cx);
5507    toggle_expand_dir(&panel, "root/dir2", cx);
5508
5509    // Test Case 1: Delete middle file in directory
5510    select_path(&panel, "root/dir1/file1.txt", cx);
5511    assert_eq!(
5512        visible_entries_as_strings(&panel, 0..15, cx),
5513        &[
5514            "v root",
5515            "    v dir1",
5516            "        > subdir1",
5517            "          file1.txt  <== selected",
5518            "          file2.txt",
5519            "    v dir2",
5520            "        > subdir2",
5521            "          file3.txt",
5522            "          file4.txt",
5523            "      file5.txt",
5524            "      file6.txt",
5525        ],
5526        "Initial state before deleting middle file"
5527    );
5528
5529    submit_deletion(&panel, cx);
5530    assert_eq!(
5531        visible_entries_as_strings(&panel, 0..15, cx),
5532        &[
5533            "v root",
5534            "    v dir1",
5535            "        > subdir1",
5536            "          file2.txt  <== selected",
5537            "    v dir2",
5538            "        > subdir2",
5539            "          file3.txt",
5540            "          file4.txt",
5541            "      file5.txt",
5542            "      file6.txt",
5543        ],
5544        "Should select next file after deleting middle file"
5545    );
5546
5547    // Test Case 2: Delete last file in directory
5548    submit_deletion(&panel, cx);
5549    assert_eq!(
5550        visible_entries_as_strings(&panel, 0..15, cx),
5551        &[
5552            "v root",
5553            "    v dir1",
5554            "        > subdir1  <== selected",
5555            "    v dir2",
5556            "        > subdir2",
5557            "          file3.txt",
5558            "          file4.txt",
5559            "      file5.txt",
5560            "      file6.txt",
5561        ],
5562        "Should select next directory when last file is deleted"
5563    );
5564
5565    // Test Case 3: Delete root level file
5566    select_path(&panel, "root/file6.txt", cx);
5567    assert_eq!(
5568        visible_entries_as_strings(&panel, 0..15, cx),
5569        &[
5570            "v root",
5571            "    v dir1",
5572            "        > subdir1",
5573            "    v dir2",
5574            "        > subdir2",
5575            "          file3.txt",
5576            "          file4.txt",
5577            "      file5.txt",
5578            "      file6.txt  <== selected",
5579        ],
5580        "Initial state before deleting root level file"
5581    );
5582
5583    submit_deletion(&panel, cx);
5584    assert_eq!(
5585        visible_entries_as_strings(&panel, 0..15, cx),
5586        &[
5587            "v root",
5588            "    v dir1",
5589            "        > subdir1",
5590            "    v dir2",
5591            "        > subdir2",
5592            "          file3.txt",
5593            "          file4.txt",
5594            "      file5.txt  <== selected",
5595        ],
5596        "Should select prev entry at root level"
5597    );
5598}
5599
5600#[gpui::test]
5601async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
5602    init_test_with_editor(cx);
5603
5604    let fs = FakeFs::new(cx.executor());
5605    fs.insert_tree(
5606        path!("/root"),
5607        json!({
5608            "aa": "// Testing 1",
5609            "bb": "// Testing 2",
5610            "cc": "// Testing 3",
5611            "dd": "// Testing 4",
5612            "ee": "// Testing 5",
5613            "ff": "// Testing 6",
5614            "gg": "// Testing 7",
5615            "hh": "// Testing 8",
5616            "ii": "// Testing 8",
5617            ".gitignore": "bb\ndd\nee\nff\nii\n'",
5618        }),
5619    )
5620    .await;
5621
5622    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5623    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5624    let workspace = window
5625        .read_with(cx, |mw, _| mw.workspace().clone())
5626        .unwrap();
5627    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5628
5629    // Test 1: Auto selection with one gitignored file next to the deleted file
5630    cx.update(|_, cx| {
5631        let settings = *ProjectPanelSettings::get_global(cx);
5632        ProjectPanelSettings::override_global(
5633            ProjectPanelSettings {
5634                hide_gitignore: true,
5635                ..settings
5636            },
5637            cx,
5638        );
5639    });
5640
5641    let panel = workspace.update_in(cx, ProjectPanel::new);
5642    cx.run_until_parked();
5643
5644    select_path(&panel, "root/aa", cx);
5645    assert_eq!(
5646        visible_entries_as_strings(&panel, 0..10, cx),
5647        &[
5648            "v root",
5649            "      .gitignore",
5650            "      aa  <== selected",
5651            "      cc",
5652            "      gg",
5653            "      hh"
5654        ],
5655        "Initial state should hide files on .gitignore"
5656    );
5657
5658    submit_deletion(&panel, cx);
5659
5660    assert_eq!(
5661        visible_entries_as_strings(&panel, 0..10, cx),
5662        &[
5663            "v root",
5664            "      .gitignore",
5665            "      cc  <== selected",
5666            "      gg",
5667            "      hh"
5668        ],
5669        "Should select next entry not on .gitignore"
5670    );
5671
5672    // Test 2: Auto selection with many gitignored files next to the deleted file
5673    submit_deletion(&panel, cx);
5674    assert_eq!(
5675        visible_entries_as_strings(&panel, 0..10, cx),
5676        &[
5677            "v root",
5678            "      .gitignore",
5679            "      gg  <== selected",
5680            "      hh"
5681        ],
5682        "Should select next entry not on .gitignore"
5683    );
5684
5685    // Test 3: Auto selection of entry before deleted file
5686    select_path(&panel, "root/hh", cx);
5687    assert_eq!(
5688        visible_entries_as_strings(&panel, 0..10, cx),
5689        &[
5690            "v root",
5691            "      .gitignore",
5692            "      gg",
5693            "      hh  <== selected"
5694        ],
5695        "Should select next entry not on .gitignore"
5696    );
5697    submit_deletion(&panel, cx);
5698    assert_eq!(
5699        visible_entries_as_strings(&panel, 0..10, cx),
5700        &["v root", "      .gitignore", "      gg  <== selected"],
5701        "Should select next entry not on .gitignore"
5702    );
5703}
5704
5705#[gpui::test]
5706async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
5707    init_test_with_editor(cx);
5708
5709    let fs = FakeFs::new(cx.executor());
5710    fs.insert_tree(
5711        path!("/root"),
5712        json!({
5713            "dir1": {
5714                "file1": "// Testing",
5715                "file2": "// Testing",
5716                "file3": "// Testing"
5717            },
5718            "aa": "// Testing",
5719            ".gitignore": "file1\nfile3\n",
5720        }),
5721    )
5722    .await;
5723
5724    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5725    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5726    let workspace = window
5727        .read_with(cx, |mw, _| mw.workspace().clone())
5728        .unwrap();
5729    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5730
5731    cx.update(|_, cx| {
5732        let settings = *ProjectPanelSettings::get_global(cx);
5733        ProjectPanelSettings::override_global(
5734            ProjectPanelSettings {
5735                hide_gitignore: true,
5736                ..settings
5737            },
5738            cx,
5739        );
5740    });
5741
5742    let panel = workspace.update_in(cx, ProjectPanel::new);
5743    cx.run_until_parked();
5744
5745    // Test 1: Visible items should exclude files on gitignore
5746    toggle_expand_dir(&panel, "root/dir1", cx);
5747    select_path(&panel, "root/dir1/file2", cx);
5748    assert_eq!(
5749        visible_entries_as_strings(&panel, 0..10, cx),
5750        &[
5751            "v root",
5752            "    v dir1",
5753            "          file2  <== selected",
5754            "      .gitignore",
5755            "      aa"
5756        ],
5757        "Initial state should hide files on .gitignore"
5758    );
5759    submit_deletion(&panel, cx);
5760
5761    // Test 2: Auto selection should go to the parent
5762    assert_eq!(
5763        visible_entries_as_strings(&panel, 0..10, cx),
5764        &[
5765            "v root",
5766            "    v dir1  <== selected",
5767            "      .gitignore",
5768            "      aa"
5769        ],
5770        "Initial state should hide files on .gitignore"
5771    );
5772}
5773
5774#[gpui::test]
5775async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
5776    init_test_with_editor(cx);
5777
5778    let fs = FakeFs::new(cx.executor());
5779    fs.insert_tree(
5780        "/root",
5781        json!({
5782            "dir1": {
5783                "subdir1": {
5784                    "a.txt": "",
5785                    "b.txt": ""
5786                },
5787                "file1.txt": "",
5788            },
5789            "dir2": {
5790                "subdir2": {
5791                    "c.txt": "",
5792                    "d.txt": ""
5793                },
5794                "file2.txt": "",
5795            },
5796            "file3.txt": "",
5797        }),
5798    )
5799    .await;
5800
5801    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5802    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5803    let workspace = window
5804        .read_with(cx, |mw, _| mw.workspace().clone())
5805        .unwrap();
5806    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5807    let panel = workspace.update_in(cx, ProjectPanel::new);
5808    cx.run_until_parked();
5809
5810    toggle_expand_dir(&panel, "root/dir1", cx);
5811    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5812    toggle_expand_dir(&panel, "root/dir2", cx);
5813    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5814
5815    // Test Case 1: Select and delete nested directory with parent
5816    cx.simulate_modifiers_change(gpui::Modifiers {
5817        control: true,
5818        ..Default::default()
5819    });
5820    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5821    select_path_with_mark(&panel, "root/dir1", cx);
5822
5823    assert_eq!(
5824        visible_entries_as_strings(&panel, 0..15, cx),
5825        &[
5826            "v root",
5827            "    v dir1  <== selected  <== marked",
5828            "        v subdir1  <== marked",
5829            "              a.txt",
5830            "              b.txt",
5831            "          file1.txt",
5832            "    v dir2",
5833            "        v subdir2",
5834            "              c.txt",
5835            "              d.txt",
5836            "          file2.txt",
5837            "      file3.txt",
5838        ],
5839        "Initial state before deleting nested directory with parent"
5840    );
5841
5842    submit_deletion(&panel, cx);
5843    assert_eq!(
5844        visible_entries_as_strings(&panel, 0..15, cx),
5845        &[
5846            "v root",
5847            "    v dir2  <== selected",
5848            "        v subdir2",
5849            "              c.txt",
5850            "              d.txt",
5851            "          file2.txt",
5852            "      file3.txt",
5853        ],
5854        "Should select next directory after deleting directory with parent"
5855    );
5856
5857    // Test Case 2: Select mixed files and directories across levels
5858    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
5859    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
5860    select_path_with_mark(&panel, "root/file3.txt", cx);
5861
5862    assert_eq!(
5863        visible_entries_as_strings(&panel, 0..15, cx),
5864        &[
5865            "v root",
5866            "    v dir2",
5867            "        v subdir2",
5868            "              c.txt  <== marked",
5869            "              d.txt",
5870            "          file2.txt  <== marked",
5871            "      file3.txt  <== selected  <== marked",
5872        ],
5873        "Initial state before deleting"
5874    );
5875
5876    submit_deletion(&panel, cx);
5877    assert_eq!(
5878        visible_entries_as_strings(&panel, 0..15, cx),
5879        &[
5880            "v root",
5881            "    v dir2  <== selected",
5882            "        v subdir2",
5883            "              d.txt",
5884        ],
5885        "Should select sibling directory"
5886    );
5887}
5888
5889#[gpui::test]
5890async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
5891    init_test_with_editor(cx);
5892
5893    let fs = FakeFs::new(cx.executor());
5894    fs.insert_tree(
5895        "/root",
5896        json!({
5897            "dir1": {
5898                "subdir1": {
5899                    "a.txt": "",
5900                    "b.txt": ""
5901                },
5902                "file1.txt": "",
5903            },
5904            "dir2": {
5905                "subdir2": {
5906                    "c.txt": "",
5907                    "d.txt": ""
5908                },
5909                "file2.txt": "",
5910            },
5911            "file3.txt": "",
5912            "file4.txt": "",
5913        }),
5914    )
5915    .await;
5916
5917    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5918    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5919    let workspace = window
5920        .read_with(cx, |mw, _| mw.workspace().clone())
5921        .unwrap();
5922    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5923    let panel = workspace.update_in(cx, ProjectPanel::new);
5924    cx.run_until_parked();
5925
5926    toggle_expand_dir(&panel, "root/dir1", cx);
5927    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5928    toggle_expand_dir(&panel, "root/dir2", cx);
5929    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5930
5931    // Test Case 1: Select all root files and directories
5932    cx.simulate_modifiers_change(gpui::Modifiers {
5933        control: true,
5934        ..Default::default()
5935    });
5936    select_path_with_mark(&panel, "root/dir1", cx);
5937    select_path_with_mark(&panel, "root/dir2", cx);
5938    select_path_with_mark(&panel, "root/file3.txt", cx);
5939    select_path_with_mark(&panel, "root/file4.txt", cx);
5940    assert_eq!(
5941        visible_entries_as_strings(&panel, 0..20, cx),
5942        &[
5943            "v root",
5944            "    v dir1  <== marked",
5945            "        v subdir1",
5946            "              a.txt",
5947            "              b.txt",
5948            "          file1.txt",
5949            "    v dir2  <== marked",
5950            "        v subdir2",
5951            "              c.txt",
5952            "              d.txt",
5953            "          file2.txt",
5954            "      file3.txt  <== marked",
5955            "      file4.txt  <== selected  <== marked",
5956        ],
5957        "State before deleting all contents"
5958    );
5959
5960    submit_deletion(&panel, cx);
5961    assert_eq!(
5962        visible_entries_as_strings(&panel, 0..20, cx),
5963        &["v root  <== selected"],
5964        "Only empty root directory should remain after deleting all contents"
5965    );
5966}
5967
5968#[gpui::test]
5969async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
5970    init_test_with_editor(cx);
5971
5972    let fs = FakeFs::new(cx.executor());
5973    fs.insert_tree(
5974        "/root",
5975        json!({
5976            "dir1": {
5977                "subdir1": {
5978                    "file_a.txt": "content a",
5979                    "file_b.txt": "content b",
5980                },
5981                "subdir2": {
5982                    "file_c.txt": "content c",
5983                },
5984                "file1.txt": "content 1",
5985            },
5986            "dir2": {
5987                "file2.txt": "content 2",
5988            },
5989        }),
5990    )
5991    .await;
5992
5993    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5994    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5995    let workspace = window
5996        .read_with(cx, |mw, _| mw.workspace().clone())
5997        .unwrap();
5998    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5999    let panel = workspace.update_in(cx, ProjectPanel::new);
6000    cx.run_until_parked();
6001
6002    toggle_expand_dir(&panel, "root/dir1", cx);
6003    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6004    toggle_expand_dir(&panel, "root/dir2", cx);
6005    cx.simulate_modifiers_change(gpui::Modifiers {
6006        control: true,
6007        ..Default::default()
6008    });
6009
6010    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6011    select_path_with_mark(&panel, "root/dir1", cx);
6012    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6013    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6014
6015    assert_eq!(
6016        visible_entries_as_strings(&panel, 0..20, cx),
6017        &[
6018            "v root",
6019            "    v dir1  <== marked",
6020            "        v subdir1  <== marked",
6021            "              file_a.txt  <== selected  <== marked",
6022            "              file_b.txt",
6023            "        > subdir2",
6024            "          file1.txt",
6025            "    v dir2",
6026            "          file2.txt",
6027        ],
6028        "State with parent dir, subdir, and file selected"
6029    );
6030    submit_deletion(&panel, cx);
6031    assert_eq!(
6032        visible_entries_as_strings(&panel, 0..20, cx),
6033        &["v root", "    v dir2  <== selected", "          file2.txt",],
6034        "Only dir2 should remain after deletion"
6035    );
6036}
6037
6038#[gpui::test]
6039async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
6040    init_test_with_editor(cx);
6041
6042    let fs = FakeFs::new(cx.executor());
6043    // First worktree
6044    fs.insert_tree(
6045        "/root1",
6046        json!({
6047            "dir1": {
6048                "file1.txt": "content 1",
6049                "file2.txt": "content 2",
6050            },
6051            "dir2": {
6052                "file3.txt": "content 3",
6053            },
6054        }),
6055    )
6056    .await;
6057
6058    // Second worktree
6059    fs.insert_tree(
6060        "/root2",
6061        json!({
6062            "dir3": {
6063                "file4.txt": "content 4",
6064                "file5.txt": "content 5",
6065            },
6066            "file6.txt": "content 6",
6067        }),
6068    )
6069    .await;
6070
6071    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6072    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6073    let workspace = window
6074        .read_with(cx, |mw, _| mw.workspace().clone())
6075        .unwrap();
6076    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6077    let panel = workspace.update_in(cx, ProjectPanel::new);
6078    cx.run_until_parked();
6079
6080    // Expand all directories for testing
6081    toggle_expand_dir(&panel, "root1/dir1", cx);
6082    toggle_expand_dir(&panel, "root1/dir2", cx);
6083    toggle_expand_dir(&panel, "root2/dir3", cx);
6084
6085    // Test Case 1: Delete files across different worktrees
6086    cx.simulate_modifiers_change(gpui::Modifiers {
6087        control: true,
6088        ..Default::default()
6089    });
6090    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
6091    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
6092
6093    assert_eq!(
6094        visible_entries_as_strings(&panel, 0..20, cx),
6095        &[
6096            "v root1",
6097            "    v dir1",
6098            "          file1.txt  <== marked",
6099            "          file2.txt",
6100            "    v dir2",
6101            "          file3.txt",
6102            "v root2",
6103            "    v dir3",
6104            "          file4.txt  <== selected  <== marked",
6105            "          file5.txt",
6106            "      file6.txt",
6107        ],
6108        "Initial state with files selected from different worktrees"
6109    );
6110
6111    submit_deletion(&panel, cx);
6112    assert_eq!(
6113        visible_entries_as_strings(&panel, 0..20, cx),
6114        &[
6115            "v root1",
6116            "    v dir1",
6117            "          file2.txt",
6118            "    v dir2",
6119            "          file3.txt",
6120            "v root2",
6121            "    v dir3",
6122            "          file5.txt  <== selected",
6123            "      file6.txt",
6124        ],
6125        "Should select next file in the last worktree after deletion"
6126    );
6127
6128    // Test Case 2: Delete directories from different worktrees
6129    select_path_with_mark(&panel, "root1/dir1", cx);
6130    select_path_with_mark(&panel, "root2/dir3", cx);
6131
6132    assert_eq!(
6133        visible_entries_as_strings(&panel, 0..20, cx),
6134        &[
6135            "v root1",
6136            "    v dir1  <== marked",
6137            "          file2.txt",
6138            "    v dir2",
6139            "          file3.txt",
6140            "v root2",
6141            "    v dir3  <== selected  <== marked",
6142            "          file5.txt",
6143            "      file6.txt",
6144        ],
6145        "State with directories marked from different worktrees"
6146    );
6147
6148    submit_deletion(&panel, cx);
6149    assert_eq!(
6150        visible_entries_as_strings(&panel, 0..20, cx),
6151        &[
6152            "v root1",
6153            "    v dir2",
6154            "          file3.txt",
6155            "v root2",
6156            "      file6.txt  <== selected",
6157        ],
6158        "Should select remaining file in last worktree after directory deletion"
6159    );
6160
6161    // Test Case 4: Delete all remaining files except roots
6162    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
6163    select_path_with_mark(&panel, "root2/file6.txt", cx);
6164
6165    assert_eq!(
6166        visible_entries_as_strings(&panel, 0..20, cx),
6167        &[
6168            "v root1",
6169            "    v dir2",
6170            "          file3.txt  <== marked",
6171            "v root2",
6172            "      file6.txt  <== selected  <== marked",
6173        ],
6174        "State with all remaining files marked"
6175    );
6176
6177    submit_deletion(&panel, cx);
6178    assert_eq!(
6179        visible_entries_as_strings(&panel, 0..20, cx),
6180        &["v root1", "    v dir2", "v root2  <== selected"],
6181        "Second parent root should be selected after deleting"
6182    );
6183}
6184
6185#[gpui::test]
6186async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
6187    init_test_with_editor(cx);
6188
6189    let fs = FakeFs::new(cx.executor());
6190    fs.insert_tree(
6191        "/root",
6192        json!({
6193            "dir1": {
6194                "file1.txt": "",
6195                "file2.txt": "",
6196                "file3.txt": "",
6197            },
6198            "dir2": {
6199                "file4.txt": "",
6200                "file5.txt": "",
6201            },
6202        }),
6203    )
6204    .await;
6205
6206    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6207    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6208    let workspace = window
6209        .read_with(cx, |mw, _| mw.workspace().clone())
6210        .unwrap();
6211    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6212    let panel = workspace.update_in(cx, ProjectPanel::new);
6213    cx.run_until_parked();
6214
6215    toggle_expand_dir(&panel, "root/dir1", cx);
6216    toggle_expand_dir(&panel, "root/dir2", cx);
6217
6218    cx.simulate_modifiers_change(gpui::Modifiers {
6219        control: true,
6220        ..Default::default()
6221    });
6222
6223    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6224    select_path(&panel, "root/dir1/file1.txt", cx);
6225
6226    assert_eq!(
6227        visible_entries_as_strings(&panel, 0..15, cx),
6228        &[
6229            "v root",
6230            "    v dir1",
6231            "          file1.txt  <== selected",
6232            "          file2.txt  <== marked",
6233            "          file3.txt",
6234            "    v dir2",
6235            "          file4.txt",
6236            "          file5.txt",
6237        ],
6238        "Initial state with one marked entry and different selection"
6239    );
6240
6241    // Delete should operate on the selected entry (file1.txt)
6242    submit_deletion(&panel, cx);
6243    assert_eq!(
6244        visible_entries_as_strings(&panel, 0..15, cx),
6245        &[
6246            "v root",
6247            "    v dir1",
6248            "          file2.txt  <== selected  <== marked",
6249            "          file3.txt",
6250            "    v dir2",
6251            "          file4.txt",
6252            "          file5.txt",
6253        ],
6254        "Should delete selected file, not marked file"
6255    );
6256
6257    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6258    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6259    select_path(&panel, "root/dir2/file5.txt", cx);
6260
6261    assert_eq!(
6262        visible_entries_as_strings(&panel, 0..15, cx),
6263        &[
6264            "v root",
6265            "    v dir1",
6266            "          file2.txt  <== marked",
6267            "          file3.txt  <== marked",
6268            "    v dir2",
6269            "          file4.txt  <== marked",
6270            "          file5.txt  <== selected",
6271        ],
6272        "Initial state with multiple marked entries and different selection"
6273    );
6274
6275    // Delete should operate on all marked entries, ignoring the selection
6276    submit_deletion(&panel, cx);
6277    assert_eq!(
6278        visible_entries_as_strings(&panel, 0..15, cx),
6279        &[
6280            "v root",
6281            "    v dir1",
6282            "    v dir2",
6283            "          file5.txt  <== selected",
6284        ],
6285        "Should delete all marked files, leaving only the selected file"
6286    );
6287}
6288
6289#[gpui::test]
6290async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6291    init_test_with_editor(cx);
6292
6293    let fs = FakeFs::new(cx.executor());
6294    fs.insert_tree(
6295        "/root_b",
6296        json!({
6297            "dir1": {
6298                "file1.txt": "content 1",
6299                "file2.txt": "content 2",
6300            },
6301        }),
6302    )
6303    .await;
6304
6305    fs.insert_tree(
6306        "/root_c",
6307        json!({
6308            "dir2": {},
6309        }),
6310    )
6311    .await;
6312
6313    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6314    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6315    let workspace = window
6316        .read_with(cx, |mw, _| mw.workspace().clone())
6317        .unwrap();
6318    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6319    let panel = workspace.update_in(cx, ProjectPanel::new);
6320    cx.run_until_parked();
6321
6322    toggle_expand_dir(&panel, "root_b/dir1", cx);
6323    toggle_expand_dir(&panel, "root_c/dir2", cx);
6324
6325    cx.simulate_modifiers_change(gpui::Modifiers {
6326        control: true,
6327        ..Default::default()
6328    });
6329    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6330    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6331
6332    assert_eq!(
6333        visible_entries_as_strings(&panel, 0..20, cx),
6334        &[
6335            "v root_b",
6336            "    v dir1",
6337            "          file1.txt  <== marked",
6338            "          file2.txt  <== selected  <== marked",
6339            "v root_c",
6340            "    v dir2",
6341        ],
6342        "Initial state with files marked in root_b"
6343    );
6344
6345    submit_deletion(&panel, cx);
6346    assert_eq!(
6347        visible_entries_as_strings(&panel, 0..20, cx),
6348        &[
6349            "v root_b",
6350            "    v dir1  <== selected",
6351            "v root_c",
6352            "    v dir2",
6353        ],
6354        "After deletion in root_b as it's last deletion, selection should be in root_b"
6355    );
6356
6357    select_path_with_mark(&panel, "root_c/dir2", cx);
6358
6359    submit_deletion(&panel, cx);
6360    assert_eq!(
6361        visible_entries_as_strings(&panel, 0..20, cx),
6362        &["v root_b", "    v dir1", "v root_c  <== selected",],
6363        "After deleting from root_c, it should remain in root_c"
6364    );
6365}
6366
6367fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6368    let path = rel_path(path);
6369    panel.update_in(cx, |panel, window, cx| {
6370        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6371            let worktree = worktree.read(cx);
6372            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6373                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6374                panel.toggle_expanded(entry_id, window, cx);
6375                return;
6376            }
6377        }
6378        panic!("no worktree for path {:?}", path);
6379    });
6380    cx.run_until_parked();
6381}
6382
6383#[gpui::test]
6384async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6385    init_test_with_editor(cx);
6386
6387    let fs = FakeFs::new(cx.executor());
6388    fs.insert_tree(
6389        path!("/root"),
6390        json!({
6391            ".gitignore": "**/ignored_dir\n**/ignored_nested",
6392            "dir1": {
6393                "empty1": {
6394                    "empty2": {
6395                        "empty3": {
6396                            "file.txt": ""
6397                        }
6398                    }
6399                },
6400                "subdir1": {
6401                    "file1.txt": "",
6402                    "file2.txt": "",
6403                    "ignored_nested": {
6404                        "ignored_file.txt": ""
6405                    }
6406                },
6407                "ignored_dir": {
6408                    "subdir": {
6409                        "deep_file.txt": ""
6410                    }
6411                }
6412            }
6413        }),
6414    )
6415    .await;
6416
6417    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6418    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6419    let workspace = window
6420        .read_with(cx, |mw, _| mw.workspace().clone())
6421        .unwrap();
6422    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6423
6424    // Test 1: When auto-fold is enabled
6425    cx.update(|_, cx| {
6426        let settings = *ProjectPanelSettings::get_global(cx);
6427        ProjectPanelSettings::override_global(
6428            ProjectPanelSettings {
6429                auto_fold_dirs: true,
6430                ..settings
6431            },
6432            cx,
6433        );
6434    });
6435
6436    let panel = workspace.update_in(cx, ProjectPanel::new);
6437    cx.run_until_parked();
6438
6439    assert_eq!(
6440        visible_entries_as_strings(&panel, 0..20, cx),
6441        &["v root", "    > dir1", "      .gitignore",],
6442        "Initial state should show collapsed root structure"
6443    );
6444
6445    toggle_expand_dir(&panel, "root/dir1", cx);
6446    assert_eq!(
6447        visible_entries_as_strings(&panel, 0..20, cx),
6448        &[
6449            "v root",
6450            "    v dir1  <== selected",
6451            "        > empty1/empty2/empty3",
6452            "        > ignored_dir",
6453            "        > subdir1",
6454            "      .gitignore",
6455        ],
6456        "Should show first level with auto-folded dirs and ignored dir visible"
6457    );
6458
6459    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6460    panel.update_in(cx, |panel, window, cx| {
6461        let project = panel.project.read(cx);
6462        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6463        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6464        panel.update_visible_entries(None, false, false, window, cx);
6465    });
6466    cx.run_until_parked();
6467
6468    assert_eq!(
6469        visible_entries_as_strings(&panel, 0..20, cx),
6470        &[
6471            "v root",
6472            "    v dir1  <== selected",
6473            "        v empty1",
6474            "            v empty2",
6475            "                v empty3",
6476            "                      file.txt",
6477            "        > ignored_dir",
6478            "        v subdir1",
6479            "            > ignored_nested",
6480            "              file1.txt",
6481            "              file2.txt",
6482            "      .gitignore",
6483        ],
6484        "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6485    );
6486
6487    // Test 2: When auto-fold is disabled
6488    cx.update(|_, cx| {
6489        let settings = *ProjectPanelSettings::get_global(cx);
6490        ProjectPanelSettings::override_global(
6491            ProjectPanelSettings {
6492                auto_fold_dirs: false,
6493                ..settings
6494            },
6495            cx,
6496        );
6497    });
6498
6499    panel.update_in(cx, |panel, window, cx| {
6500        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6501    });
6502
6503    toggle_expand_dir(&panel, "root/dir1", cx);
6504    assert_eq!(
6505        visible_entries_as_strings(&panel, 0..20, cx),
6506        &[
6507            "v root",
6508            "    v dir1  <== selected",
6509            "        > empty1",
6510            "        > ignored_dir",
6511            "        > subdir1",
6512            "      .gitignore",
6513        ],
6514        "With auto-fold disabled: should show all directories separately"
6515    );
6516
6517    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6518    panel.update_in(cx, |panel, window, cx| {
6519        let project = panel.project.read(cx);
6520        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6521        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6522        panel.update_visible_entries(None, false, false, window, cx);
6523    });
6524    cx.run_until_parked();
6525
6526    assert_eq!(
6527        visible_entries_as_strings(&panel, 0..20, cx),
6528        &[
6529            "v root",
6530            "    v dir1  <== selected",
6531            "        v empty1",
6532            "            v empty2",
6533            "                v empty3",
6534            "                      file.txt",
6535            "        > ignored_dir",
6536            "        v subdir1",
6537            "            > ignored_nested",
6538            "              file1.txt",
6539            "              file2.txt",
6540            "      .gitignore",
6541        ],
6542        "After expand_all without auto-fold: should expand all dirs normally, \
6543         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6544    );
6545
6546    // Test 3: When explicitly called on ignored directory
6547    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6548    panel.update_in(cx, |panel, window, cx| {
6549        let project = panel.project.read(cx);
6550        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6551        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6552        panel.update_visible_entries(None, false, false, window, cx);
6553    });
6554    cx.run_until_parked();
6555
6556    assert_eq!(
6557        visible_entries_as_strings(&panel, 0..20, cx),
6558        &[
6559            "v root",
6560            "    v dir1  <== selected",
6561            "        v empty1",
6562            "            v empty2",
6563            "                v empty3",
6564            "                      file.txt",
6565            "        v ignored_dir",
6566            "            v subdir",
6567            "                  deep_file.txt",
6568            "        v subdir1",
6569            "            > ignored_nested",
6570            "              file1.txt",
6571            "              file2.txt",
6572            "      .gitignore",
6573        ],
6574        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
6575    );
6576}
6577
6578#[gpui::test]
6579async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
6580    init_test(cx);
6581
6582    let fs = FakeFs::new(cx.executor());
6583    fs.insert_tree(
6584        path!("/root"),
6585        json!({
6586            "dir1": {
6587                "subdir1": {
6588                    "nested1": {
6589                        "file1.txt": "",
6590                        "file2.txt": ""
6591                    },
6592                },
6593                "subdir2": {
6594                    "file4.txt": ""
6595                }
6596            },
6597            "dir2": {
6598                "single_file": {
6599                    "file5.txt": ""
6600                }
6601            }
6602        }),
6603    )
6604    .await;
6605
6606    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6607    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6608    let workspace = window
6609        .read_with(cx, |mw, _| mw.workspace().clone())
6610        .unwrap();
6611    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6612
6613    // Test 1: Basic collapsing
6614    {
6615        let panel = workspace.update_in(cx, ProjectPanel::new);
6616        cx.run_until_parked();
6617
6618        toggle_expand_dir(&panel, "root/dir1", cx);
6619        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6620        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6621        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6622
6623        assert_eq!(
6624            visible_entries_as_strings(&panel, 0..20, cx),
6625            &[
6626                "v root",
6627                "    v dir1",
6628                "        v subdir1",
6629                "            v nested1",
6630                "                  file1.txt",
6631                "                  file2.txt",
6632                "        v subdir2  <== selected",
6633                "              file4.txt",
6634                "    > dir2",
6635            ],
6636            "Initial state with everything expanded"
6637        );
6638
6639        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6640        panel.update_in(cx, |panel, window, cx| {
6641            let project = panel.project.read(cx);
6642            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6643            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6644            panel.update_visible_entries(None, false, false, window, cx);
6645        });
6646        cx.run_until_parked();
6647
6648        assert_eq!(
6649            visible_entries_as_strings(&panel, 0..20, cx),
6650            &["v root", "    > dir1", "    > dir2",],
6651            "All subdirs under dir1 should be collapsed"
6652        );
6653    }
6654
6655    // Test 2: With auto-fold enabled
6656    {
6657        cx.update(|_, cx| {
6658            let settings = *ProjectPanelSettings::get_global(cx);
6659            ProjectPanelSettings::override_global(
6660                ProjectPanelSettings {
6661                    auto_fold_dirs: true,
6662                    ..settings
6663                },
6664                cx,
6665            );
6666        });
6667
6668        let panel = workspace.update_in(cx, ProjectPanel::new);
6669        cx.run_until_parked();
6670
6671        toggle_expand_dir(&panel, "root/dir1", cx);
6672        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6673        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6674
6675        assert_eq!(
6676            visible_entries_as_strings(&panel, 0..20, cx),
6677            &[
6678                "v root",
6679                "    v dir1",
6680                "        v subdir1/nested1  <== selected",
6681                "              file1.txt",
6682                "              file2.txt",
6683                "        > subdir2",
6684                "    > dir2/single_file",
6685            ],
6686            "Initial state with some dirs expanded"
6687        );
6688
6689        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6690        panel.update(cx, |panel, cx| {
6691            let project = panel.project.read(cx);
6692            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6693            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6694        });
6695
6696        toggle_expand_dir(&panel, "root/dir1", cx);
6697
6698        assert_eq!(
6699            visible_entries_as_strings(&panel, 0..20, cx),
6700            &[
6701                "v root",
6702                "    v dir1  <== selected",
6703                "        > subdir1/nested1",
6704                "        > subdir2",
6705                "    > dir2/single_file",
6706            ],
6707            "Subdirs should be collapsed and folded with auto-fold enabled"
6708        );
6709    }
6710
6711    // Test 3: With auto-fold disabled
6712    {
6713        cx.update(|_, cx| {
6714            let settings = *ProjectPanelSettings::get_global(cx);
6715            ProjectPanelSettings::override_global(
6716                ProjectPanelSettings {
6717                    auto_fold_dirs: false,
6718                    ..settings
6719                },
6720                cx,
6721            );
6722        });
6723
6724        let panel = workspace.update_in(cx, ProjectPanel::new);
6725        cx.run_until_parked();
6726
6727        toggle_expand_dir(&panel, "root/dir1", cx);
6728        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6729        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6730
6731        assert_eq!(
6732            visible_entries_as_strings(&panel, 0..20, cx),
6733            &[
6734                "v root",
6735                "    v dir1",
6736                "        v subdir1",
6737                "            v nested1  <== selected",
6738                "                  file1.txt",
6739                "                  file2.txt",
6740                "        > subdir2",
6741                "    > dir2",
6742            ],
6743            "Initial state with some dirs expanded and auto-fold disabled"
6744        );
6745
6746        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6747        panel.update(cx, |panel, cx| {
6748            let project = panel.project.read(cx);
6749            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6750            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6751        });
6752
6753        toggle_expand_dir(&panel, "root/dir1", cx);
6754
6755        assert_eq!(
6756            visible_entries_as_strings(&panel, 0..20, cx),
6757            &[
6758                "v root",
6759                "    v dir1  <== selected",
6760                "        > subdir1",
6761                "        > subdir2",
6762                "    > dir2",
6763            ],
6764            "Subdirs should be collapsed but not folded with auto-fold disabled"
6765        );
6766    }
6767}
6768
6769#[gpui::test]
6770async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
6771    init_test(cx);
6772
6773    let fs = FakeFs::new(cx.executor());
6774    fs.insert_tree(
6775        path!("/root"),
6776        json!({
6777            "dir1": {
6778                "subdir1": {
6779                    "nested1": {
6780                        "file1.txt": "",
6781                        "file2.txt": ""
6782                    },
6783                },
6784                "subdir2": {
6785                    "file3.txt": ""
6786                }
6787            },
6788            "dir2": {
6789                "file4.txt": ""
6790            }
6791        }),
6792    )
6793    .await;
6794
6795    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6796    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6797    let workspace = window
6798        .read_with(cx, |mw, _| mw.workspace().clone())
6799        .unwrap();
6800    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6801
6802    let panel = workspace.update_in(cx, ProjectPanel::new);
6803    cx.run_until_parked();
6804
6805    toggle_expand_dir(&panel, "root/dir1", cx);
6806    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6807    toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6808    toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6809    toggle_expand_dir(&panel, "root/dir2", cx);
6810
6811    assert_eq!(
6812        visible_entries_as_strings(&panel, 0..20, cx),
6813        &[
6814            "v root",
6815            "    v dir1",
6816            "        v subdir1",
6817            "            v nested1",
6818            "                  file1.txt",
6819            "                  file2.txt",
6820            "        v subdir2",
6821            "              file3.txt",
6822            "    v dir2  <== selected",
6823            "          file4.txt",
6824        ],
6825        "Initial state with directories expanded"
6826    );
6827
6828    select_path(&panel, "root/dir1", cx);
6829    cx.run_until_parked();
6830
6831    panel.update_in(cx, |panel, window, cx| {
6832        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6833    });
6834    cx.run_until_parked();
6835
6836    assert_eq!(
6837        visible_entries_as_strings(&panel, 0..20, cx),
6838        &[
6839            "v root",
6840            "    > dir1  <== selected",
6841            "    v dir2",
6842            "          file4.txt",
6843        ],
6844        "dir1 and all its children should be collapsed, dir2 should remain expanded"
6845    );
6846
6847    toggle_expand_dir(&panel, "root/dir1", cx);
6848    cx.run_until_parked();
6849
6850    assert_eq!(
6851        visible_entries_as_strings(&panel, 0..20, cx),
6852        &[
6853            "v root",
6854            "    v dir1  <== selected",
6855            "        > subdir1",
6856            "        > subdir2",
6857            "    v dir2",
6858            "          file4.txt",
6859        ],
6860        "After re-expanding dir1, its children should still be collapsed"
6861    );
6862}
6863
6864#[gpui::test]
6865async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
6866    init_test(cx);
6867
6868    let fs = FakeFs::new(cx.executor());
6869    fs.insert_tree(
6870        path!("/root"),
6871        json!({
6872            "dir1": {
6873                "subdir1": {
6874                    "file1.txt": ""
6875                },
6876                "file2.txt": ""
6877            },
6878            "dir2": {
6879                "file3.txt": ""
6880            }
6881        }),
6882    )
6883    .await;
6884
6885    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6886    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6887    let workspace = window
6888        .read_with(cx, |mw, _| mw.workspace().clone())
6889        .unwrap();
6890    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6891
6892    let panel = workspace.update_in(cx, ProjectPanel::new);
6893    cx.run_until_parked();
6894
6895    toggle_expand_dir(&panel, "root/dir1", cx);
6896    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6897    toggle_expand_dir(&panel, "root/dir2", cx);
6898
6899    assert_eq!(
6900        visible_entries_as_strings(&panel, 0..20, cx),
6901        &[
6902            "v root",
6903            "    v dir1",
6904            "        v subdir1",
6905            "              file1.txt",
6906            "          file2.txt",
6907            "    v dir2  <== selected",
6908            "          file3.txt",
6909        ],
6910        "Initial state with directories expanded"
6911    );
6912
6913    // Select the root and collapse it and its children
6914    select_path(&panel, "root", cx);
6915    cx.run_until_parked();
6916
6917    panel.update_in(cx, |panel, window, cx| {
6918        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6919    });
6920    cx.run_until_parked();
6921
6922    // The root and all its children should be collapsed
6923    assert_eq!(
6924        visible_entries_as_strings(&panel, 0..20, cx),
6925        &["> root  <== selected"],
6926        "Root and all children should be collapsed"
6927    );
6928
6929    // Re-expand root and dir1, verify children were recursively collapsed
6930    toggle_expand_dir(&panel, "root", cx);
6931    toggle_expand_dir(&panel, "root/dir1", cx);
6932    cx.run_until_parked();
6933
6934    assert_eq!(
6935        visible_entries_as_strings(&panel, 0..20, cx),
6936        &[
6937            "v root",
6938            "    v dir1  <== selected",
6939            "        > subdir1",
6940            "          file2.txt",
6941            "    > dir2",
6942        ],
6943        "After re-expanding root and dir1, subdir1 should still be collapsed"
6944    );
6945}
6946
6947#[gpui::test]
6948async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
6949    init_test(cx);
6950
6951    let fs = FakeFs::new(cx.executor());
6952    fs.insert_tree(
6953        path!("/root1"),
6954        json!({
6955            "dir1": {
6956                "subdir1": {
6957                    "file1.txt": ""
6958                },
6959                "file2.txt": ""
6960            }
6961        }),
6962    )
6963    .await;
6964    fs.insert_tree(
6965        path!("/root2"),
6966        json!({
6967            "dir2": {
6968                "file3.txt": ""
6969            },
6970            "file4.txt": ""
6971        }),
6972    )
6973    .await;
6974
6975    let project = Project::test(
6976        fs.clone(),
6977        [path!("/root1").as_ref(), path!("/root2").as_ref()],
6978        cx,
6979    )
6980    .await;
6981    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6982    let workspace = window
6983        .read_with(cx, |mw, _| mw.workspace().clone())
6984        .unwrap();
6985    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6986
6987    let panel = workspace.update_in(cx, ProjectPanel::new);
6988    cx.run_until_parked();
6989
6990    toggle_expand_dir(&panel, "root1/dir1", cx);
6991    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
6992    toggle_expand_dir(&panel, "root2/dir2", cx);
6993
6994    assert_eq!(
6995        visible_entries_as_strings(&panel, 0..20, cx),
6996        &[
6997            "v root1",
6998            "    v dir1",
6999            "        v subdir1",
7000            "              file1.txt",
7001            "          file2.txt",
7002            "v root2",
7003            "    v dir2  <== selected",
7004            "          file3.txt",
7005            "      file4.txt",
7006        ],
7007        "Initial state with directories expanded across worktrees"
7008    );
7009
7010    // Select root1 and collapse it and its children.
7011    // In a multi-worktree project, this should only collapse the selected worktree,
7012    // leaving other worktrees unaffected.
7013    select_path(&panel, "root1", cx);
7014    cx.run_until_parked();
7015
7016    panel.update_in(cx, |panel, window, cx| {
7017        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7018    });
7019    cx.run_until_parked();
7020
7021    assert_eq!(
7022        visible_entries_as_strings(&panel, 0..20, cx),
7023        &[
7024            "> root1  <== selected",
7025            "v root2",
7026            "    v dir2",
7027            "          file3.txt",
7028            "      file4.txt",
7029        ],
7030        "Only root1 should be collapsed, root2 should remain expanded"
7031    );
7032
7033    // Re-expand root1 and verify its children were recursively collapsed
7034    toggle_expand_dir(&panel, "root1", cx);
7035
7036    assert_eq!(
7037        visible_entries_as_strings(&panel, 0..20, cx),
7038        &[
7039            "v root1  <== selected",
7040            "    > dir1",
7041            "v root2",
7042            "    v dir2",
7043            "          file3.txt",
7044            "      file4.txt",
7045        ],
7046        "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
7047    );
7048}
7049
7050#[gpui::test]
7051async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7052    init_test(cx);
7053
7054    let fs = FakeFs::new(cx.executor());
7055    fs.insert_tree(
7056        path!("/root1"),
7057        json!({
7058            "dir1": {
7059                "subdir1": {
7060                    "file1.txt": ""
7061                },
7062                "file2.txt": ""
7063            }
7064        }),
7065    )
7066    .await;
7067    fs.insert_tree(
7068        path!("/root2"),
7069        json!({
7070            "dir2": {
7071                "subdir2": {
7072                    "file3.txt": ""
7073                },
7074                "file4.txt": ""
7075            }
7076        }),
7077    )
7078    .await;
7079
7080    let project = Project::test(
7081        fs.clone(),
7082        [path!("/root1").as_ref(), path!("/root2").as_ref()],
7083        cx,
7084    )
7085    .await;
7086    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7087    let workspace = window
7088        .read_with(cx, |mw, _| mw.workspace().clone())
7089        .unwrap();
7090    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7091
7092    let panel = workspace.update_in(cx, ProjectPanel::new);
7093    cx.run_until_parked();
7094
7095    toggle_expand_dir(&panel, "root1/dir1", cx);
7096    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7097    toggle_expand_dir(&panel, "root2/dir2", cx);
7098    toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
7099
7100    assert_eq!(
7101        visible_entries_as_strings(&panel, 0..20, cx),
7102        &[
7103            "v root1",
7104            "    v dir1",
7105            "        v subdir1",
7106            "              file1.txt",
7107            "          file2.txt",
7108            "v root2",
7109            "    v dir2",
7110            "        v subdir2  <== selected",
7111            "              file3.txt",
7112            "          file4.txt",
7113        ],
7114        "Initial state with directories expanded across worktrees"
7115    );
7116
7117    // Select dir1 in root1 and collapse it
7118    select_path(&panel, "root1/dir1", cx);
7119    cx.run_until_parked();
7120
7121    panel.update_in(cx, |panel, window, cx| {
7122        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7123    });
7124    cx.run_until_parked();
7125
7126    assert_eq!(
7127        visible_entries_as_strings(&panel, 0..20, cx),
7128        &[
7129            "v root1",
7130            "    > dir1  <== selected",
7131            "v root2",
7132            "    v dir2",
7133            "        v subdir2",
7134            "              file3.txt",
7135            "          file4.txt",
7136        ],
7137        "Only dir1 should be collapsed, root2 should be completely unaffected"
7138    );
7139
7140    // Re-expand dir1 and verify subdir1 was recursively collapsed
7141    toggle_expand_dir(&panel, "root1/dir1", cx);
7142
7143    assert_eq!(
7144        visible_entries_as_strings(&panel, 0..20, cx),
7145        &[
7146            "v root1",
7147            "    v dir1  <== selected",
7148            "        > subdir1",
7149            "          file2.txt",
7150            "v root2",
7151            "    v dir2",
7152            "        v subdir2",
7153            "              file3.txt",
7154            "          file4.txt",
7155        ],
7156        "After re-expanding dir1, subdir1 should still be collapsed"
7157    );
7158}
7159
7160#[gpui::test]
7161async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
7162    init_test(cx);
7163
7164    let fs = FakeFs::new(cx.executor());
7165    fs.insert_tree(
7166        path!("/root"),
7167        json!({
7168            "dir1": {
7169                "subdir1": {
7170                    "file1.txt": ""
7171                },
7172                "file2.txt": ""
7173            },
7174            "dir2": {
7175                "file3.txt": ""
7176            }
7177        }),
7178    )
7179    .await;
7180
7181    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7182    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7183    let workspace = window
7184        .read_with(cx, |mw, _| mw.workspace().clone())
7185        .unwrap();
7186    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7187
7188    let panel = workspace.update_in(cx, ProjectPanel::new);
7189    cx.run_until_parked();
7190
7191    toggle_expand_dir(&panel, "root/dir1", cx);
7192    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7193    toggle_expand_dir(&panel, "root/dir2", cx);
7194
7195    assert_eq!(
7196        visible_entries_as_strings(&panel, 0..20, cx),
7197        &[
7198            "v root",
7199            "    v dir1",
7200            "        v subdir1",
7201            "              file1.txt",
7202            "          file2.txt",
7203            "    v dir2  <== selected",
7204            "          file3.txt",
7205        ],
7206        "Initial state with directories expanded"
7207    );
7208
7209    select_path(&panel, "root", cx);
7210    cx.run_until_parked();
7211
7212    panel.update_in(cx, |panel, window, cx| {
7213        panel.collapse_all_for_root(window, cx);
7214    });
7215    cx.run_until_parked();
7216
7217    assert_eq!(
7218        visible_entries_as_strings(&panel, 0..20, cx),
7219        &["v root  <== selected", "    > dir1", "    > dir2"],
7220        "Root should remain expanded but all children should be collapsed"
7221    );
7222
7223    toggle_expand_dir(&panel, "root/dir1", cx);
7224    cx.run_until_parked();
7225
7226    assert_eq!(
7227        visible_entries_as_strings(&panel, 0..20, cx),
7228        &[
7229            "v root",
7230            "    v dir1  <== selected",
7231            "        > subdir1",
7232            "          file2.txt",
7233            "    > dir2",
7234        ],
7235        "After re-expanding dir1, subdir1 should still be collapsed"
7236    );
7237}
7238
7239#[gpui::test]
7240async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7241    init_test(cx);
7242
7243    let fs = FakeFs::new(cx.executor());
7244    fs.insert_tree(
7245        path!("/root1"),
7246        json!({
7247            "dir1": {
7248                "subdir1": {
7249                    "file1.txt": ""
7250                },
7251                "file2.txt": ""
7252            }
7253        }),
7254    )
7255    .await;
7256    fs.insert_tree(
7257        path!("/root2"),
7258        json!({
7259            "dir2": {
7260                "file3.txt": ""
7261            },
7262            "file4.txt": ""
7263        }),
7264    )
7265    .await;
7266
7267    let project = Project::test(
7268        fs.clone(),
7269        [path!("/root1").as_ref(), path!("/root2").as_ref()],
7270        cx,
7271    )
7272    .await;
7273    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7274    let workspace = window
7275        .read_with(cx, |mw, _| mw.workspace().clone())
7276        .unwrap();
7277    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7278
7279    let panel = workspace.update_in(cx, ProjectPanel::new);
7280    cx.run_until_parked();
7281
7282    toggle_expand_dir(&panel, "root1/dir1", cx);
7283    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7284    toggle_expand_dir(&panel, "root2/dir2", cx);
7285
7286    assert_eq!(
7287        visible_entries_as_strings(&panel, 0..20, cx),
7288        &[
7289            "v root1",
7290            "    v dir1",
7291            "        v subdir1",
7292            "              file1.txt",
7293            "          file2.txt",
7294            "v root2",
7295            "    v dir2  <== selected",
7296            "          file3.txt",
7297            "      file4.txt",
7298        ],
7299        "Initial state with directories expanded across worktrees"
7300    );
7301
7302    select_path(&panel, "root1", cx);
7303    cx.run_until_parked();
7304
7305    panel.update_in(cx, |panel, window, cx| {
7306        panel.collapse_all_for_root(window, cx);
7307    });
7308    cx.run_until_parked();
7309
7310    assert_eq!(
7311        visible_entries_as_strings(&panel, 0..20, cx),
7312        &[
7313            "> root1  <== selected",
7314            "v root2",
7315            "    v dir2",
7316            "          file3.txt",
7317            "      file4.txt",
7318        ],
7319        "With multiple worktrees, root1 should collapse completely (including itself)"
7320    );
7321}
7322
7323#[gpui::test]
7324async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
7325    init_test(cx);
7326
7327    let fs = FakeFs::new(cx.executor());
7328    fs.insert_tree(
7329        path!("/root"),
7330        json!({
7331            "dir1": {
7332                "subdir1": {
7333                    "file1.txt": ""
7334                },
7335            },
7336            "dir2": {
7337                "file2.txt": ""
7338            }
7339        }),
7340    )
7341    .await;
7342
7343    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7344    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7345    let workspace = window
7346        .read_with(cx, |mw, _| mw.workspace().clone())
7347        .unwrap();
7348    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7349
7350    let panel = workspace.update_in(cx, ProjectPanel::new);
7351    cx.run_until_parked();
7352
7353    toggle_expand_dir(&panel, "root/dir1", cx);
7354    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7355    toggle_expand_dir(&panel, "root/dir2", cx);
7356
7357    assert_eq!(
7358        visible_entries_as_strings(&panel, 0..20, cx),
7359        &[
7360            "v root",
7361            "    v dir1",
7362            "        v subdir1",
7363            "              file1.txt",
7364            "    v dir2  <== selected",
7365            "          file2.txt",
7366        ],
7367        "Initial state with directories expanded"
7368    );
7369
7370    select_path(&panel, "root/dir1", cx);
7371    cx.run_until_parked();
7372
7373    panel.update_in(cx, |panel, window, cx| {
7374        panel.collapse_all_for_root(window, cx);
7375    });
7376    cx.run_until_parked();
7377
7378    assert_eq!(
7379        visible_entries_as_strings(&panel, 0..20, cx),
7380        &[
7381            "v root",
7382            "    v dir1  <== selected",
7383            "        v subdir1",
7384            "              file1.txt",
7385            "    v dir2",
7386            "          file2.txt",
7387        ],
7388        "collapse_all_for_root should be a no-op when called on a non-root directory"
7389    );
7390}
7391
7392#[gpui::test]
7393async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
7394    init_test(cx);
7395
7396    let fs = FakeFs::new(cx.executor());
7397    fs.insert_tree(
7398        path!("/root"),
7399        json!({
7400            "dir1": {
7401                "file1.txt": "",
7402            },
7403        }),
7404    )
7405    .await;
7406
7407    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7408    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7409    let workspace = window
7410        .read_with(cx, |mw, _| mw.workspace().clone())
7411        .unwrap();
7412    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7413
7414    let panel = workspace.update_in(cx, |workspace, window, cx| {
7415        let panel = ProjectPanel::new(workspace, window, cx);
7416        workspace.add_panel(panel.clone(), window, cx);
7417        panel
7418    });
7419    cx.run_until_parked();
7420
7421    #[rustfmt::skip]
7422    assert_eq!(
7423        visible_entries_as_strings(&panel, 0..20, cx),
7424        &[
7425            "v root",
7426            "    > dir1",
7427        ],
7428        "Initial state with nothing selected"
7429    );
7430
7431    panel.update_in(cx, |panel, window, cx| {
7432        panel.new_file(&NewFile, window, cx);
7433    });
7434    cx.run_until_parked();
7435    panel.update_in(cx, |panel, window, cx| {
7436        assert!(panel.filename_editor.read(cx).is_focused(window));
7437    });
7438    panel
7439        .update_in(cx, |panel, window, cx| {
7440            panel.filename_editor.update(cx, |editor, cx| {
7441                editor.set_text("hello_from_no_selections", window, cx)
7442            });
7443            panel.confirm_edit(true, window, cx).unwrap()
7444        })
7445        .await
7446        .unwrap();
7447    cx.run_until_parked();
7448    #[rustfmt::skip]
7449    assert_eq!(
7450        visible_entries_as_strings(&panel, 0..20, cx),
7451        &[
7452            "v root",
7453            "    > dir1",
7454            "      hello_from_no_selections  <== selected  <== marked",
7455        ],
7456        "A new file is created under the root directory"
7457    );
7458}
7459
7460#[gpui::test]
7461async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
7462    init_test(cx);
7463
7464    let fs = FakeFs::new(cx.executor());
7465    fs.insert_tree(
7466        path!("/root"),
7467        json!({
7468            "existing_dir": {
7469                "existing_file.txt": "",
7470            },
7471            "existing_file.txt": "",
7472        }),
7473    )
7474    .await;
7475
7476    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7477    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7478    let workspace = window
7479        .read_with(cx, |mw, _| mw.workspace().clone())
7480        .unwrap();
7481    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7482
7483    cx.update(|_, cx| {
7484        let settings = *ProjectPanelSettings::get_global(cx);
7485        ProjectPanelSettings::override_global(
7486            ProjectPanelSettings {
7487                hide_root: true,
7488                ..settings
7489            },
7490            cx,
7491        );
7492    });
7493
7494    let panel = workspace.update_in(cx, |workspace, window, cx| {
7495        let panel = ProjectPanel::new(workspace, window, cx);
7496        workspace.add_panel(panel.clone(), window, cx);
7497        panel
7498    });
7499    cx.run_until_parked();
7500
7501    #[rustfmt::skip]
7502    assert_eq!(
7503        visible_entries_as_strings(&panel, 0..20, cx),
7504        &[
7505            "> existing_dir",
7506            "  existing_file.txt",
7507        ],
7508        "Initial state with hide_root=true, root should be hidden and nothing selected"
7509    );
7510
7511    panel.update(cx, |panel, _| {
7512        assert!(
7513            panel.selection.is_none(),
7514            "Should have no selection initially"
7515        );
7516    });
7517
7518    // Test 1: Create new file when no entry is selected
7519    panel.update_in(cx, |panel, window, cx| {
7520        panel.new_file(&NewFile, window, cx);
7521    });
7522    cx.run_until_parked();
7523    panel.update_in(cx, |panel, window, cx| {
7524        assert!(panel.filename_editor.read(cx).is_focused(window));
7525    });
7526    cx.run_until_parked();
7527    #[rustfmt::skip]
7528    assert_eq!(
7529        visible_entries_as_strings(&panel, 0..20, cx),
7530        &[
7531            "> existing_dir",
7532            "  [EDITOR: '']  <== selected",
7533            "  existing_file.txt",
7534        ],
7535        "Editor should appear at root level when hide_root=true and no selection"
7536    );
7537
7538    let confirm = panel.update_in(cx, |panel, window, cx| {
7539        panel.filename_editor.update(cx, |editor, cx| {
7540            editor.set_text("new_file_at_root.txt", window, cx)
7541        });
7542        panel.confirm_edit(true, window, cx).unwrap()
7543    });
7544    confirm.await.unwrap();
7545    cx.run_until_parked();
7546
7547    #[rustfmt::skip]
7548    assert_eq!(
7549        visible_entries_as_strings(&panel, 0..20, cx),
7550        &[
7551            "> existing_dir",
7552            "  existing_file.txt",
7553            "  new_file_at_root.txt  <== selected  <== marked",
7554        ],
7555        "New file should be created at root level and visible without root prefix"
7556    );
7557
7558    assert!(
7559        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
7560        "File should be created in the actual root directory"
7561    );
7562
7563    // Test 2: Create new directory when no entry is selected
7564    panel.update(cx, |panel, _| {
7565        panel.selection = None;
7566    });
7567
7568    panel.update_in(cx, |panel, window, cx| {
7569        panel.new_directory(&NewDirectory, window, cx);
7570    });
7571    cx.run_until_parked();
7572
7573    panel.update_in(cx, |panel, window, cx| {
7574        assert!(panel.filename_editor.read(cx).is_focused(window));
7575    });
7576
7577    #[rustfmt::skip]
7578    assert_eq!(
7579        visible_entries_as_strings(&panel, 0..20, cx),
7580        &[
7581            "> [EDITOR: '']  <== selected",
7582            "> existing_dir",
7583            "  existing_file.txt",
7584            "  new_file_at_root.txt",
7585        ],
7586        "Directory editor should appear at root level when hide_root=true and no selection"
7587    );
7588
7589    let confirm = panel.update_in(cx, |panel, window, cx| {
7590        panel.filename_editor.update(cx, |editor, cx| {
7591            editor.set_text("new_dir_at_root", window, cx)
7592        });
7593        panel.confirm_edit(true, window, cx).unwrap()
7594    });
7595    confirm.await.unwrap();
7596    cx.run_until_parked();
7597
7598    #[rustfmt::skip]
7599    assert_eq!(
7600        visible_entries_as_strings(&panel, 0..20, cx),
7601        &[
7602            "> existing_dir",
7603            "v new_dir_at_root  <== selected",
7604            "  existing_file.txt",
7605            "  new_file_at_root.txt",
7606        ],
7607        "New directory should be created at root level and visible without root prefix"
7608    );
7609
7610    assert!(
7611        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
7612        "Directory should be created in the actual root directory"
7613    );
7614}
7615
7616#[cfg(windows)]
7617#[gpui::test]
7618async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
7619    init_test(cx);
7620
7621    let fs = FakeFs::new(cx.executor());
7622    fs.insert_tree(
7623        path!("/root"),
7624        json!({
7625            "dir1": {
7626                "file1.txt": "",
7627            },
7628        }),
7629    )
7630    .await;
7631
7632    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7633    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7634    let workspace = window
7635        .read_with(cx, |mw, _| mw.workspace().clone())
7636        .unwrap();
7637    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7638
7639    let panel = workspace.update_in(cx, |workspace, window, cx| {
7640        let panel = ProjectPanel::new(workspace, window, cx);
7641        workspace.add_panel(panel.clone(), window, cx);
7642        panel
7643    });
7644    cx.run_until_parked();
7645
7646    #[rustfmt::skip]
7647    assert_eq!(
7648        visible_entries_as_strings(&panel, 0..20, cx),
7649        &[
7650            "v root",
7651            "    > dir1",
7652        ],
7653        "Initial state with nothing selected"
7654    );
7655
7656    panel.update_in(cx, |panel, window, cx| {
7657        panel.new_file(&NewFile, window, cx);
7658    });
7659    cx.run_until_parked();
7660    panel.update_in(cx, |panel, window, cx| {
7661        assert!(panel.filename_editor.read(cx).is_focused(window));
7662    });
7663    panel
7664        .update_in(cx, |panel, window, cx| {
7665            panel
7666                .filename_editor
7667                .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
7668            panel.confirm_edit(true, window, cx).unwrap()
7669        })
7670        .await
7671        .unwrap();
7672    cx.run_until_parked();
7673    #[rustfmt::skip]
7674    assert_eq!(
7675        visible_entries_as_strings(&panel, 0..20, cx),
7676        &[
7677            "v root",
7678            "    > dir1",
7679            "      foo  <== selected  <== marked",
7680        ],
7681        "A new file is created under the root directory without the trailing dot"
7682    );
7683}
7684
7685#[gpui::test]
7686async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
7687    init_test(cx);
7688
7689    let fs = FakeFs::new(cx.executor());
7690    fs.insert_tree(
7691        "/root",
7692        json!({
7693            "dir1": {
7694                "file1.txt": "",
7695                "dir2": {
7696                    "file2.txt": ""
7697                }
7698            },
7699            "file3.txt": ""
7700        }),
7701    )
7702    .await;
7703
7704    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7705    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7706    let workspace = window
7707        .read_with(cx, |mw, _| mw.workspace().clone())
7708        .unwrap();
7709    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7710    let panel = workspace.update_in(cx, ProjectPanel::new);
7711    cx.run_until_parked();
7712
7713    panel.update(cx, |panel, cx| {
7714        let project = panel.project.read(cx);
7715        let worktree = project.visible_worktrees(cx).next().unwrap();
7716        let worktree = worktree.read(cx);
7717
7718        // Test 1: Target is a directory, should highlight the directory itself
7719        let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
7720        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
7721        assert_eq!(
7722            result,
7723            Some(dir_entry.id),
7724            "Should highlight directory itself"
7725        );
7726
7727        // Test 2: Target is nested file, should highlight immediate parent
7728        let nested_file = worktree
7729            .entry_for_path(rel_path("dir1/dir2/file2.txt"))
7730            .unwrap();
7731        let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
7732        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
7733        assert_eq!(
7734            result,
7735            Some(nested_parent.id),
7736            "Should highlight immediate parent"
7737        );
7738
7739        // Test 3: Target is root level file, should highlight root
7740        let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
7741        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
7742        assert_eq!(
7743            result,
7744            Some(worktree.root_entry().unwrap().id),
7745            "Root level file should return None"
7746        );
7747
7748        // Test 4: Target is root itself, should highlight root
7749        let root_entry = worktree.root_entry().unwrap();
7750        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
7751        assert_eq!(
7752            result,
7753            Some(root_entry.id),
7754            "Root level file should return None"
7755        );
7756    });
7757}
7758
7759#[gpui::test]
7760async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
7761    init_test(cx);
7762
7763    let fs = FakeFs::new(cx.executor());
7764    fs.insert_tree(
7765        "/root",
7766        json!({
7767            "parent_dir": {
7768                "child_file.txt": "",
7769                "sibling_file.txt": "",
7770                "child_dir": {
7771                    "nested_file.txt": ""
7772                }
7773            },
7774            "other_dir": {
7775                "other_file.txt": ""
7776            }
7777        }),
7778    )
7779    .await;
7780
7781    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7782    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7783    let workspace = window
7784        .read_with(cx, |mw, _| mw.workspace().clone())
7785        .unwrap();
7786    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7787    let panel = workspace.update_in(cx, ProjectPanel::new);
7788    cx.run_until_parked();
7789
7790    panel.update(cx, |panel, cx| {
7791        let project = panel.project.read(cx);
7792        let worktree = project.visible_worktrees(cx).next().unwrap();
7793        let worktree_id = worktree.read(cx).id();
7794        let worktree = worktree.read(cx);
7795
7796        let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
7797        let child_file = worktree
7798            .entry_for_path(rel_path("parent_dir/child_file.txt"))
7799            .unwrap();
7800        let sibling_file = worktree
7801            .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
7802            .unwrap();
7803        let child_dir = worktree
7804            .entry_for_path(rel_path("parent_dir/child_dir"))
7805            .unwrap();
7806        let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
7807        let other_file = worktree
7808            .entry_for_path(rel_path("other_dir/other_file.txt"))
7809            .unwrap();
7810
7811        // Test 1: Single item drag, don't highlight parent directory
7812        let dragged_selection = DraggedSelection {
7813            active_selection: SelectedEntry {
7814                worktree_id,
7815                entry_id: child_file.id,
7816            },
7817            marked_selections: Arc::new([SelectedEntry {
7818                worktree_id,
7819                entry_id: child_file.id,
7820            }]),
7821        };
7822        let result =
7823            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7824        assert_eq!(result, None, "Should not highlight parent of dragged item");
7825
7826        // Test 2: Single item drag, don't highlight sibling files
7827        let result = panel.highlight_entry_for_selection_drag(
7828            sibling_file,
7829            worktree,
7830            &dragged_selection,
7831            cx,
7832        );
7833        assert_eq!(result, None, "Should not highlight sibling files");
7834
7835        // Test 3: Single item drag, highlight unrelated directory
7836        let result =
7837            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
7838        assert_eq!(
7839            result,
7840            Some(other_dir.id),
7841            "Should highlight unrelated directory"
7842        );
7843
7844        // Test 4: Single item drag, highlight sibling directory
7845        let result =
7846            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7847        assert_eq!(
7848            result,
7849            Some(child_dir.id),
7850            "Should highlight sibling directory"
7851        );
7852
7853        // Test 5: Multiple items drag, highlight parent directory
7854        let dragged_selection = DraggedSelection {
7855            active_selection: SelectedEntry {
7856                worktree_id,
7857                entry_id: child_file.id,
7858            },
7859            marked_selections: Arc::new([
7860                SelectedEntry {
7861                    worktree_id,
7862                    entry_id: child_file.id,
7863                },
7864                SelectedEntry {
7865                    worktree_id,
7866                    entry_id: sibling_file.id,
7867                },
7868            ]),
7869        };
7870        let result =
7871            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7872        assert_eq!(
7873            result,
7874            Some(parent_dir.id),
7875            "Should highlight parent with multiple items"
7876        );
7877
7878        // Test 6: Target is file in different directory, highlight parent
7879        let result =
7880            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
7881        assert_eq!(
7882            result,
7883            Some(other_dir.id),
7884            "Should highlight parent of target file"
7885        );
7886
7887        // Test 7: Target is directory, always highlight
7888        let result =
7889            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7890        assert_eq!(
7891            result,
7892            Some(child_dir.id),
7893            "Should always highlight directories"
7894        );
7895    });
7896}
7897
7898#[gpui::test]
7899async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
7900    init_test(cx);
7901
7902    let fs = FakeFs::new(cx.executor());
7903    fs.insert_tree(
7904        "/root1",
7905        json!({
7906            "src": {
7907                "main.rs": "",
7908                "lib.rs": ""
7909            }
7910        }),
7911    )
7912    .await;
7913    fs.insert_tree(
7914        "/root2",
7915        json!({
7916            "src": {
7917                "main.rs": "",
7918                "test.rs": ""
7919            }
7920        }),
7921    )
7922    .await;
7923
7924    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7925    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7926    let workspace = window
7927        .read_with(cx, |mw, _| mw.workspace().clone())
7928        .unwrap();
7929    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7930    let panel = workspace.update_in(cx, ProjectPanel::new);
7931    cx.run_until_parked();
7932
7933    panel.update(cx, |panel, cx| {
7934        let project = panel.project.read(cx);
7935        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7936
7937        let worktree_a = &worktrees[0];
7938        let main_rs_from_a = worktree_a
7939            .read(cx)
7940            .entry_for_path(rel_path("src/main.rs"))
7941            .unwrap();
7942
7943        let worktree_b = &worktrees[1];
7944        let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
7945        let main_rs_from_b = worktree_b
7946            .read(cx)
7947            .entry_for_path(rel_path("src/main.rs"))
7948            .unwrap();
7949
7950        // Test dragging file from worktree A onto parent of file with same relative path in worktree B
7951        let dragged_selection = DraggedSelection {
7952            active_selection: SelectedEntry {
7953                worktree_id: worktree_a.read(cx).id(),
7954                entry_id: main_rs_from_a.id,
7955            },
7956            marked_selections: Arc::new([SelectedEntry {
7957                worktree_id: worktree_a.read(cx).id(),
7958                entry_id: main_rs_from_a.id,
7959            }]),
7960        };
7961
7962        let result = panel.highlight_entry_for_selection_drag(
7963            src_dir_from_b,
7964            worktree_b.read(cx),
7965            &dragged_selection,
7966            cx,
7967        );
7968        assert_eq!(
7969            result,
7970            Some(src_dir_from_b.id),
7971            "Should highlight target directory from different worktree even with same relative path"
7972        );
7973
7974        // Test dragging file from worktree A onto file with same relative path in worktree B
7975        let result = panel.highlight_entry_for_selection_drag(
7976            main_rs_from_b,
7977            worktree_b.read(cx),
7978            &dragged_selection,
7979            cx,
7980        );
7981        assert_eq!(
7982            result,
7983            Some(src_dir_from_b.id),
7984            "Should highlight parent of target file from different worktree"
7985        );
7986    });
7987}
7988
7989#[gpui::test]
7990async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
7991    init_test(cx);
7992
7993    let fs = FakeFs::new(cx.executor());
7994    fs.insert_tree(
7995        "/root1",
7996        json!({
7997            "parent_dir": {
7998                "child_file.txt": "",
7999                "nested_dir": {
8000                    "nested_file.txt": ""
8001                }
8002            },
8003            "root_file.txt": ""
8004        }),
8005    )
8006    .await;
8007
8008    fs.insert_tree(
8009        "/root2",
8010        json!({
8011            "other_dir": {
8012                "other_file.txt": ""
8013            }
8014        }),
8015    )
8016    .await;
8017
8018    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8019    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8020    let workspace = window
8021        .read_with(cx, |mw, _| mw.workspace().clone())
8022        .unwrap();
8023    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8024    let panel = workspace.update_in(cx, ProjectPanel::new);
8025    cx.run_until_parked();
8026
8027    panel.update(cx, |panel, cx| {
8028        let project = panel.project.read(cx);
8029        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8030        let worktree1 = worktrees[0].read(cx);
8031        let worktree2 = worktrees[1].read(cx);
8032        let worktree1_id = worktree1.id();
8033        let _worktree2_id = worktree2.id();
8034
8035        let root1_entry = worktree1.root_entry().unwrap();
8036        let root2_entry = worktree2.root_entry().unwrap();
8037        let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
8038        let child_file = worktree1
8039            .entry_for_path(rel_path("parent_dir/child_file.txt"))
8040            .unwrap();
8041        let nested_file = worktree1
8042            .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
8043            .unwrap();
8044        let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
8045
8046        // Test 1: Multiple entries - should always highlight background
8047        let multiple_dragged_selection = DraggedSelection {
8048            active_selection: SelectedEntry {
8049                worktree_id: worktree1_id,
8050                entry_id: child_file.id,
8051            },
8052            marked_selections: Arc::new([
8053                SelectedEntry {
8054                    worktree_id: worktree1_id,
8055                    entry_id: child_file.id,
8056                },
8057                SelectedEntry {
8058                    worktree_id: worktree1_id,
8059                    entry_id: nested_file.id,
8060                },
8061            ]),
8062        };
8063
8064        let result = panel.should_highlight_background_for_selection_drag(
8065            &multiple_dragged_selection,
8066            root1_entry.id,
8067            cx,
8068        );
8069        assert!(result, "Should highlight background for multiple entries");
8070
8071        // Test 2: Single entry with non-empty parent path - should highlight background
8072        let nested_dragged_selection = DraggedSelection {
8073            active_selection: SelectedEntry {
8074                worktree_id: worktree1_id,
8075                entry_id: nested_file.id,
8076            },
8077            marked_selections: Arc::new([SelectedEntry {
8078                worktree_id: worktree1_id,
8079                entry_id: nested_file.id,
8080            }]),
8081        };
8082
8083        let result = panel.should_highlight_background_for_selection_drag(
8084            &nested_dragged_selection,
8085            root1_entry.id,
8086            cx,
8087        );
8088        assert!(result, "Should highlight background for nested file");
8089
8090        // Test 3: Single entry at root level, same worktree - should NOT highlight background
8091        let root_file_dragged_selection = DraggedSelection {
8092            active_selection: SelectedEntry {
8093                worktree_id: worktree1_id,
8094                entry_id: root_file.id,
8095            },
8096            marked_selections: Arc::new([SelectedEntry {
8097                worktree_id: worktree1_id,
8098                entry_id: root_file.id,
8099            }]),
8100        };
8101
8102        let result = panel.should_highlight_background_for_selection_drag(
8103            &root_file_dragged_selection,
8104            root1_entry.id,
8105            cx,
8106        );
8107        assert!(
8108            !result,
8109            "Should NOT highlight background for root file in same worktree"
8110        );
8111
8112        // Test 4: Single entry at root level, different worktree - should highlight background
8113        let result = panel.should_highlight_background_for_selection_drag(
8114            &root_file_dragged_selection,
8115            root2_entry.id,
8116            cx,
8117        );
8118        assert!(
8119            result,
8120            "Should highlight background for root file from different worktree"
8121        );
8122
8123        // Test 5: Single entry in subdirectory - should highlight background
8124        let child_file_dragged_selection = DraggedSelection {
8125            active_selection: SelectedEntry {
8126                worktree_id: worktree1_id,
8127                entry_id: child_file.id,
8128            },
8129            marked_selections: Arc::new([SelectedEntry {
8130                worktree_id: worktree1_id,
8131                entry_id: child_file.id,
8132            }]),
8133        };
8134
8135        let result = panel.should_highlight_background_for_selection_drag(
8136            &child_file_dragged_selection,
8137            root1_entry.id,
8138            cx,
8139        );
8140        assert!(
8141            result,
8142            "Should highlight background for file with non-empty parent path"
8143        );
8144    });
8145}
8146
8147#[gpui::test]
8148async fn test_hide_root(cx: &mut gpui::TestAppContext) {
8149    init_test(cx);
8150
8151    let fs = FakeFs::new(cx.executor());
8152    fs.insert_tree(
8153        "/root1",
8154        json!({
8155            "dir1": {
8156                "file1.txt": "content",
8157                "file2.txt": "content",
8158            },
8159            "dir2": {
8160                "file3.txt": "content",
8161            },
8162            "file4.txt": "content",
8163        }),
8164    )
8165    .await;
8166
8167    fs.insert_tree(
8168        "/root2",
8169        json!({
8170            "dir3": {
8171                "file5.txt": "content",
8172            },
8173            "file6.txt": "content",
8174        }),
8175    )
8176    .await;
8177
8178    // Test 1: Single worktree with hide_root = false
8179    {
8180        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8181        let window =
8182            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8183        let workspace = window
8184            .read_with(cx, |mw, _| mw.workspace().clone())
8185            .unwrap();
8186        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8187
8188        cx.update(|_, cx| {
8189            let settings = *ProjectPanelSettings::get_global(cx);
8190            ProjectPanelSettings::override_global(
8191                ProjectPanelSettings {
8192                    hide_root: false,
8193                    ..settings
8194                },
8195                cx,
8196            );
8197        });
8198
8199        let panel = workspace.update_in(cx, ProjectPanel::new);
8200        cx.run_until_parked();
8201
8202        #[rustfmt::skip]
8203        assert_eq!(
8204            visible_entries_as_strings(&panel, 0..10, cx),
8205            &[
8206                "v root1",
8207                "    > dir1",
8208                "    > dir2",
8209                "      file4.txt",
8210            ],
8211            "With hide_root=false and single worktree, root should be visible"
8212        );
8213    }
8214
8215    // Test 2: Single worktree with hide_root = true
8216    {
8217        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8218        let window =
8219            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8220        let workspace = window
8221            .read_with(cx, |mw, _| mw.workspace().clone())
8222            .unwrap();
8223        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8224
8225        // Set hide_root to true
8226        cx.update(|_, cx| {
8227            let settings = *ProjectPanelSettings::get_global(cx);
8228            ProjectPanelSettings::override_global(
8229                ProjectPanelSettings {
8230                    hide_root: true,
8231                    ..settings
8232                },
8233                cx,
8234            );
8235        });
8236
8237        let panel = workspace.update_in(cx, ProjectPanel::new);
8238        cx.run_until_parked();
8239
8240        assert_eq!(
8241            visible_entries_as_strings(&panel, 0..10, cx),
8242            &["> dir1", "> dir2", "  file4.txt",],
8243            "With hide_root=true and single worktree, root should be hidden"
8244        );
8245
8246        // Test expanding directories still works without root
8247        toggle_expand_dir(&panel, "root1/dir1", cx);
8248        assert_eq!(
8249            visible_entries_as_strings(&panel, 0..10, cx),
8250            &[
8251                "v dir1  <== selected",
8252                "      file1.txt",
8253                "      file2.txt",
8254                "> dir2",
8255                "  file4.txt",
8256            ],
8257            "Should be able to expand directories even when root is hidden"
8258        );
8259    }
8260
8261    // Test 3: Multiple worktrees with hide_root = true
8262    {
8263        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8264        let window =
8265            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8266        let workspace = window
8267            .read_with(cx, |mw, _| mw.workspace().clone())
8268            .unwrap();
8269        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8270
8271        // Set hide_root to true
8272        cx.update(|_, cx| {
8273            let settings = *ProjectPanelSettings::get_global(cx);
8274            ProjectPanelSettings::override_global(
8275                ProjectPanelSettings {
8276                    hide_root: true,
8277                    ..settings
8278                },
8279                cx,
8280            );
8281        });
8282
8283        let panel = workspace.update_in(cx, ProjectPanel::new);
8284        cx.run_until_parked();
8285
8286        assert_eq!(
8287            visible_entries_as_strings(&panel, 0..10, cx),
8288            &[
8289                "v root1",
8290                "    > dir1",
8291                "    > dir2",
8292                "      file4.txt",
8293                "v root2",
8294                "    > dir3",
8295                "      file6.txt",
8296            ],
8297            "With hide_root=true and multiple worktrees, roots should still be visible"
8298        );
8299    }
8300
8301    // Test 4: Multiple worktrees with hide_root = false
8302    {
8303        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8304        let window =
8305            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8306        let workspace = window
8307            .read_with(cx, |mw, _| mw.workspace().clone())
8308            .unwrap();
8309        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8310
8311        cx.update(|_, cx| {
8312            let settings = *ProjectPanelSettings::get_global(cx);
8313            ProjectPanelSettings::override_global(
8314                ProjectPanelSettings {
8315                    hide_root: false,
8316                    ..settings
8317                },
8318                cx,
8319            );
8320        });
8321
8322        let panel = workspace.update_in(cx, ProjectPanel::new);
8323        cx.run_until_parked();
8324
8325        assert_eq!(
8326            visible_entries_as_strings(&panel, 0..10, cx),
8327            &[
8328                "v root1",
8329                "    > dir1",
8330                "    > dir2",
8331                "      file4.txt",
8332                "v root2",
8333                "    > dir3",
8334                "      file6.txt",
8335            ],
8336            "With hide_root=false and multiple worktrees, roots should be visible"
8337        );
8338    }
8339}
8340
8341#[gpui::test]
8342async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
8343    init_test_with_editor(cx);
8344
8345    let fs = FakeFs::new(cx.executor());
8346    fs.insert_tree(
8347        "/root",
8348        json!({
8349            "file1.txt": "content of file1",
8350            "file2.txt": "content of file2",
8351            "dir1": {
8352                "file3.txt": "content of file3"
8353            }
8354        }),
8355    )
8356    .await;
8357
8358    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8359    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8360    let workspace = window
8361        .read_with(cx, |mw, _| mw.workspace().clone())
8362        .unwrap();
8363    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8364    let panel = workspace.update_in(cx, ProjectPanel::new);
8365    cx.run_until_parked();
8366
8367    let file1_path = "root/file1.txt";
8368    let file2_path = "root/file2.txt";
8369    select_path_with_mark(&panel, file1_path, cx);
8370    select_path_with_mark(&panel, file2_path, cx);
8371
8372    panel.update_in(cx, |panel, window, cx| {
8373        panel.compare_marked_files(&CompareMarkedFiles, window, cx);
8374    });
8375    cx.executor().run_until_parked();
8376
8377    workspace.update_in(cx, |workspace, _, cx| {
8378        let active_items = workspace
8379            .panes()
8380            .iter()
8381            .filter_map(|pane| pane.read(cx).active_item())
8382            .collect::<Vec<_>>();
8383        assert_eq!(active_items.len(), 1);
8384        let diff_view = active_items
8385            .into_iter()
8386            .next()
8387            .unwrap()
8388            .downcast::<FileDiffView>()
8389            .expect("Open item should be an FileDiffView");
8390        assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
8391        assert_eq!(
8392            diff_view.tab_tooltip_text(cx).unwrap(),
8393            format!(
8394                "{}{}",
8395                rel_path(file1_path).display(PathStyle::local()),
8396                rel_path(file2_path).display(PathStyle::local())
8397            )
8398        );
8399    });
8400
8401    let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
8402    let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
8403    let worktree_id = panel.update(cx, |panel, cx| {
8404        panel
8405            .project
8406            .read(cx)
8407            .worktrees(cx)
8408            .next()
8409            .unwrap()
8410            .read(cx)
8411            .id()
8412    });
8413
8414    let expected_entries = [
8415        SelectedEntry {
8416            worktree_id,
8417            entry_id: file1_entry_id,
8418        },
8419        SelectedEntry {
8420            worktree_id,
8421            entry_id: file2_entry_id,
8422        },
8423    ];
8424    panel.update(cx, |panel, _cx| {
8425        assert_eq!(
8426            &panel.marked_entries, &expected_entries,
8427            "Should keep marked entries after comparison"
8428        );
8429    });
8430
8431    panel.update(cx, |panel, cx| {
8432        panel.project.update(cx, |_, cx| {
8433            cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
8434        })
8435    });
8436
8437    panel.update(cx, |panel, _cx| {
8438        assert_eq!(
8439            &panel.marked_entries, &expected_entries,
8440            "Marked entries should persist after focusing back on the project panel"
8441        );
8442    });
8443}
8444
8445#[gpui::test]
8446async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
8447    init_test_with_editor(cx);
8448
8449    let fs = FakeFs::new(cx.executor());
8450    fs.insert_tree(
8451        "/root",
8452        json!({
8453            "file1.txt": "content of file1",
8454            "file2.txt": "content of file2",
8455            "dir1": {},
8456            "dir2": {
8457                "file3.txt": "content of file3"
8458            }
8459        }),
8460    )
8461    .await;
8462
8463    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8464    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8465    let workspace = window
8466        .read_with(cx, |mw, _| mw.workspace().clone())
8467        .unwrap();
8468    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8469    let panel = workspace.update_in(cx, ProjectPanel::new);
8470    cx.run_until_parked();
8471
8472    // Test 1: When only one file is selected, there should be no compare option
8473    select_path(&panel, "root/file1.txt", cx);
8474
8475    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8476    assert_eq!(
8477        selected_files, None,
8478        "Should not have compare option when only one file is selected"
8479    );
8480
8481    // Test 2: When multiple files are selected, there should be a compare option
8482    select_path_with_mark(&panel, "root/file1.txt", cx);
8483    select_path_with_mark(&panel, "root/file2.txt", cx);
8484
8485    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8486    assert!(
8487        selected_files.is_some(),
8488        "Should have files selected for comparison"
8489    );
8490    if let Some((file1, file2)) = selected_files {
8491        assert!(
8492            file1.to_string_lossy().ends_with("file1.txt")
8493                && file2.to_string_lossy().ends_with("file2.txt"),
8494            "Should have file1.txt and file2.txt as the selected files when multi-selecting"
8495        );
8496    }
8497
8498    // Test 3: Selecting a directory shouldn't count as a comparable file
8499    select_path_with_mark(&panel, "root/dir1", cx);
8500
8501    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8502    assert!(
8503        selected_files.is_some(),
8504        "Directory selection should not affect comparable files"
8505    );
8506    if let Some((file1, file2)) = selected_files {
8507        assert!(
8508            file1.to_string_lossy().ends_with("file1.txt")
8509                && file2.to_string_lossy().ends_with("file2.txt"),
8510            "Selecting a directory should not affect the number of comparable files"
8511        );
8512    }
8513
8514    // Test 4: Selecting one more file
8515    select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
8516
8517    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8518    assert!(
8519        selected_files.is_some(),
8520        "Directory selection should not affect comparable files"
8521    );
8522    if let Some((file1, file2)) = selected_files {
8523        assert!(
8524            file1.to_string_lossy().ends_with("file2.txt")
8525                && file2.to_string_lossy().ends_with("file3.txt"),
8526            "Selecting a directory should not affect the number of comparable files"
8527        );
8528    }
8529}
8530
8531#[gpui::test]
8532async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
8533    init_test(cx);
8534
8535    let fs = FakeFs::new(cx.executor());
8536    fs.insert_tree(
8537        "/root",
8538        json!({
8539            ".hidden-file.txt": "hidden file content",
8540            "visible-file.txt": "visible file content",
8541            ".hidden-parent-dir": {
8542                "nested-dir": {
8543                    "file.txt": "file content",
8544                }
8545            },
8546            "visible-dir": {
8547                "file-in-visible.txt": "file content",
8548                "nested": {
8549                    ".hidden-nested-dir": {
8550                        ".double-hidden-dir": {
8551                            "deep-file-1.txt": "deep content 1",
8552                            "deep-file-2.txt": "deep content 2"
8553                        },
8554                        "hidden-nested-file-1.txt": "hidden nested 1",
8555                        "hidden-nested-file-2.txt": "hidden nested 2"
8556                    },
8557                    "visible-nested-file.txt": "visible nested content"
8558                }
8559            }
8560        }),
8561    )
8562    .await;
8563
8564    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8565    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8566    let workspace = window
8567        .read_with(cx, |mw, _| mw.workspace().clone())
8568        .unwrap();
8569    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8570
8571    cx.update(|_, cx| {
8572        let settings = *ProjectPanelSettings::get_global(cx);
8573        ProjectPanelSettings::override_global(
8574            ProjectPanelSettings {
8575                hide_hidden: false,
8576                ..settings
8577            },
8578            cx,
8579        );
8580    });
8581
8582    let panel = workspace.update_in(cx, ProjectPanel::new);
8583    cx.run_until_parked();
8584
8585    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
8586    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
8587    toggle_expand_dir(&panel, "root/visible-dir", cx);
8588    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
8589    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
8590    toggle_expand_dir(
8591        &panel,
8592        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
8593        cx,
8594    );
8595
8596    let expanded = [
8597        "v root",
8598        "    v .hidden-parent-dir",
8599        "        v nested-dir",
8600        "              file.txt",
8601        "    v visible-dir",
8602        "        v nested",
8603        "            v .hidden-nested-dir",
8604        "                v .double-hidden-dir  <== selected",
8605        "                      deep-file-1.txt",
8606        "                      deep-file-2.txt",
8607        "                  hidden-nested-file-1.txt",
8608        "                  hidden-nested-file-2.txt",
8609        "              visible-nested-file.txt",
8610        "          file-in-visible.txt",
8611        "      .hidden-file.txt",
8612        "      visible-file.txt",
8613    ];
8614
8615    assert_eq!(
8616        visible_entries_as_strings(&panel, 0..30, cx),
8617        &expanded,
8618        "With hide_hidden=false, contents of hidden nested directory should be visible"
8619    );
8620
8621    cx.update(|_, cx| {
8622        let settings = *ProjectPanelSettings::get_global(cx);
8623        ProjectPanelSettings::override_global(
8624            ProjectPanelSettings {
8625                hide_hidden: true,
8626                ..settings
8627            },
8628            cx,
8629        );
8630    });
8631
8632    panel.update_in(cx, |panel, window, cx| {
8633        panel.update_visible_entries(None, false, false, window, cx);
8634    });
8635    cx.run_until_parked();
8636
8637    assert_eq!(
8638        visible_entries_as_strings(&panel, 0..30, cx),
8639        &[
8640            "v root",
8641            "    v visible-dir",
8642            "        v nested",
8643            "              visible-nested-file.txt",
8644            "          file-in-visible.txt",
8645            "      visible-file.txt",
8646        ],
8647        "With hide_hidden=false, contents of hidden nested directory should be visible"
8648    );
8649
8650    panel.update_in(cx, |panel, window, cx| {
8651        let settings = *ProjectPanelSettings::get_global(cx);
8652        ProjectPanelSettings::override_global(
8653            ProjectPanelSettings {
8654                hide_hidden: false,
8655                ..settings
8656            },
8657            cx,
8658        );
8659        panel.update_visible_entries(None, false, false, window, cx);
8660    });
8661    cx.run_until_parked();
8662
8663    assert_eq!(
8664        visible_entries_as_strings(&panel, 0..30, cx),
8665        &expanded,
8666        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
8667    );
8668}
8669
8670fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8671    let path = rel_path(path);
8672    panel.update_in(cx, |panel, window, cx| {
8673        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8674            let worktree = worktree.read(cx);
8675            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8676                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8677                panel.update_visible_entries(
8678                    Some((worktree.id(), entry_id)),
8679                    false,
8680                    false,
8681                    window,
8682                    cx,
8683                );
8684                return;
8685            }
8686        }
8687        panic!("no worktree for path {:?}", path);
8688    });
8689    cx.run_until_parked();
8690}
8691
8692fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8693    let path = rel_path(path);
8694    panel.update(cx, |panel, cx| {
8695        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8696            let worktree = worktree.read(cx);
8697            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8698                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8699                let entry = crate::SelectedEntry {
8700                    worktree_id: worktree.id(),
8701                    entry_id,
8702                };
8703                if !panel.marked_entries.contains(&entry) {
8704                    panel.marked_entries.push(entry);
8705                }
8706                panel.selection = Some(entry);
8707                return;
8708            }
8709        }
8710        panic!("no worktree for path {:?}", path);
8711    });
8712}
8713
8714/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
8715/// `active_ancestor_path` is the path to the folded component that should be active.
8716fn select_folded_path_with_mark(
8717    panel: &Entity<ProjectPanel>,
8718    leaf_path: &str,
8719    active_ancestor_path: &str,
8720    cx: &mut VisualTestContext,
8721) {
8722    select_path_with_mark(panel, leaf_path, cx);
8723    set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
8724}
8725
8726fn set_folded_active_ancestor(
8727    panel: &Entity<ProjectPanel>,
8728    leaf_path: &str,
8729    active_ancestor_path: &str,
8730    cx: &mut VisualTestContext,
8731) {
8732    let leaf_path = rel_path(leaf_path);
8733    let active_ancestor_path = rel_path(active_ancestor_path);
8734    panel.update(cx, |panel, cx| {
8735        let mut leaf_entry_id = None;
8736        let mut target_entry_id = None;
8737
8738        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8739            let worktree = worktree.read(cx);
8740            if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
8741                leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8742            }
8743            if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
8744                target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8745            }
8746        }
8747
8748        let leaf_entry_id =
8749            leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
8750        let target_entry_id = target_entry_id
8751            .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
8752        let folded_ancestors = panel
8753            .state
8754            .ancestors
8755            .get_mut(&leaf_entry_id)
8756            .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
8757        let ancestor_ids = folded_ancestors.ancestors.clone();
8758
8759        let mut depth_for_target = None;
8760        for depth in 0..ancestor_ids.len() {
8761            let resolved_entry_id = if depth == 0 {
8762                leaf_entry_id
8763            } else {
8764                ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
8765            };
8766            if resolved_entry_id == target_entry_id {
8767                depth_for_target = Some(depth);
8768                break;
8769            }
8770        }
8771
8772        folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
8773            panic!(
8774                "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
8775            )
8776        });
8777    });
8778}
8779
8780fn drag_selection_to(
8781    panel: &Entity<ProjectPanel>,
8782    target_path: &str,
8783    is_file: bool,
8784    cx: &mut VisualTestContext,
8785) {
8786    let target_entry = find_project_entry(panel, target_path, cx)
8787        .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
8788
8789    panel.update_in(cx, |panel, window, cx| {
8790        let selection = panel
8791            .selection
8792            .expect("a selection is required before dragging");
8793        let drag = DraggedSelection {
8794            active_selection: SelectedEntry {
8795                worktree_id: selection.worktree_id,
8796                entry_id: panel.resolve_entry(selection.entry_id),
8797            },
8798            marked_selections: Arc::from(panel.marked_entries.clone()),
8799        };
8800        panel.drag_onto(&drag, target_entry, is_file, window, cx);
8801    });
8802    cx.executor().run_until_parked();
8803}
8804
8805fn find_project_entry(
8806    panel: &Entity<ProjectPanel>,
8807    path: &str,
8808    cx: &mut VisualTestContext,
8809) -> Option<ProjectEntryId> {
8810    let path = rel_path(path);
8811    panel.update(cx, |panel, cx| {
8812        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8813            let worktree = worktree.read(cx);
8814            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8815                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8816            }
8817        }
8818        panic!("no worktree for path {path:?}");
8819    })
8820}
8821
8822fn visible_entries_as_strings(
8823    panel: &Entity<ProjectPanel>,
8824    range: Range<usize>,
8825    cx: &mut VisualTestContext,
8826) -> Vec<String> {
8827    let mut result = Vec::new();
8828    let mut project_entries = HashSet::default();
8829    let mut has_editor = false;
8830
8831    panel.update_in(cx, |panel, window, cx| {
8832        panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
8833            if details.is_editing {
8834                assert!(!has_editor, "duplicate editor entry");
8835                has_editor = true;
8836            } else {
8837                assert!(
8838                    project_entries.insert(project_entry),
8839                    "duplicate project entry {:?} {:?}",
8840                    project_entry,
8841                    details
8842                );
8843            }
8844
8845            let indent = "    ".repeat(details.depth);
8846            let icon = if details.kind.is_dir() {
8847                if details.is_expanded { "v " } else { "> " }
8848            } else {
8849                "  "
8850            };
8851            #[cfg(windows)]
8852            let filename = details.filename.replace("\\", "/");
8853            #[cfg(not(windows))]
8854            let filename = details.filename;
8855            let name = if details.is_editing {
8856                format!("[EDITOR: '{}']", filename)
8857            } else if details.is_processing {
8858                format!("[PROCESSING: '{}']", filename)
8859            } else {
8860                filename
8861            };
8862            let selected = if details.is_selected {
8863                "  <== selected"
8864            } else {
8865                ""
8866            };
8867            let marked = if details.is_marked {
8868                "  <== marked"
8869            } else {
8870                ""
8871            };
8872
8873            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8874        });
8875    });
8876
8877    result
8878}
8879
8880/// Test that missing sort_mode field defaults to DirectoriesFirst
8881#[gpui::test]
8882async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
8883    init_test(cx);
8884
8885    // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
8886    let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
8887    assert_eq!(
8888        default_settings.sort_mode,
8889        settings::ProjectPanelSortMode::DirectoriesFirst,
8890        "sort_mode should default to DirectoriesFirst"
8891    );
8892}
8893
8894/// Test sort modes: DirectoriesFirst (default) vs Mixed
8895#[gpui::test]
8896async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
8897    init_test(cx);
8898
8899    let fs = FakeFs::new(cx.executor());
8900    fs.insert_tree(
8901        "/root",
8902        json!({
8903            "zebra.txt": "",
8904            "Apple": {},
8905            "banana.rs": "",
8906            "Carrot": {},
8907            "aardvark.txt": "",
8908        }),
8909    )
8910    .await;
8911
8912    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8913    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8914    let workspace = window
8915        .read_with(cx, |mw, _| mw.workspace().clone())
8916        .unwrap();
8917    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8918    let panel = workspace.update_in(cx, ProjectPanel::new);
8919    cx.run_until_parked();
8920
8921    // Default sort mode should be DirectoriesFirst
8922    assert_eq!(
8923        visible_entries_as_strings(&panel, 0..50, cx),
8924        &[
8925            "v root",
8926            "    > Apple",
8927            "    > Carrot",
8928            "      aardvark.txt",
8929            "      banana.rs",
8930            "      zebra.txt",
8931        ]
8932    );
8933}
8934
8935#[gpui::test]
8936async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
8937    init_test(cx);
8938
8939    let fs = FakeFs::new(cx.executor());
8940    fs.insert_tree(
8941        "/root",
8942        json!({
8943            "Zebra.txt": "",
8944            "apple": {},
8945            "Banana.rs": "",
8946            "carrot": {},
8947            "Aardvark.txt": "",
8948        }),
8949    )
8950    .await;
8951
8952    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8953    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8954    let workspace = window
8955        .read_with(cx, |mw, _| mw.workspace().clone())
8956        .unwrap();
8957    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8958
8959    // Switch to Mixed mode
8960    cx.update(|_, cx| {
8961        cx.update_global::<SettingsStore, _>(|store, cx| {
8962            store.update_user_settings(cx, |settings| {
8963                settings.project_panel.get_or_insert_default().sort_mode =
8964                    Some(settings::ProjectPanelSortMode::Mixed);
8965            });
8966        });
8967    });
8968
8969    let panel = workspace.update_in(cx, ProjectPanel::new);
8970    cx.run_until_parked();
8971
8972    // Mixed mode: case-insensitive sorting
8973    // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
8974    assert_eq!(
8975        visible_entries_as_strings(&panel, 0..50, cx),
8976        &[
8977            "v root",
8978            "      Aardvark.txt",
8979            "    > apple",
8980            "      Banana.rs",
8981            "    > carrot",
8982            "      Zebra.txt",
8983        ]
8984    );
8985}
8986
8987#[gpui::test]
8988async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
8989    init_test(cx);
8990
8991    let fs = FakeFs::new(cx.executor());
8992    fs.insert_tree(
8993        "/root",
8994        json!({
8995            "Zebra.txt": "",
8996            "apple": {},
8997            "Banana.rs": "",
8998            "carrot": {},
8999            "Aardvark.txt": "",
9000        }),
9001    )
9002    .await;
9003
9004    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9005    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9006    let workspace = window
9007        .read_with(cx, |mw, _| mw.workspace().clone())
9008        .unwrap();
9009    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9010
9011    // Switch to FilesFirst mode
9012    cx.update(|_, cx| {
9013        cx.update_global::<SettingsStore, _>(|store, cx| {
9014            store.update_user_settings(cx, |settings| {
9015                settings.project_panel.get_or_insert_default().sort_mode =
9016                    Some(settings::ProjectPanelSortMode::FilesFirst);
9017            });
9018        });
9019    });
9020
9021    let panel = workspace.update_in(cx, ProjectPanel::new);
9022    cx.run_until_parked();
9023
9024    // FilesFirst mode: files first, then directories (both case-insensitive)
9025    assert_eq!(
9026        visible_entries_as_strings(&panel, 0..50, cx),
9027        &[
9028            "v root",
9029            "      Aardvark.txt",
9030            "      Banana.rs",
9031            "      Zebra.txt",
9032            "    > apple",
9033            "    > carrot",
9034        ]
9035    );
9036}
9037
9038#[gpui::test]
9039async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
9040    init_test(cx);
9041
9042    let fs = FakeFs::new(cx.executor());
9043    fs.insert_tree(
9044        "/root",
9045        json!({
9046            "file2.txt": "",
9047            "dir1": {},
9048            "file1.txt": "",
9049        }),
9050    )
9051    .await;
9052
9053    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9054    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9055    let workspace = window
9056        .read_with(cx, |mw, _| mw.workspace().clone())
9057        .unwrap();
9058    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9059    let panel = workspace.update_in(cx, ProjectPanel::new);
9060    cx.run_until_parked();
9061
9062    // Initially DirectoriesFirst
9063    assert_eq!(
9064        visible_entries_as_strings(&panel, 0..50, cx),
9065        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9066    );
9067
9068    // Toggle to Mixed
9069    cx.update(|_, cx| {
9070        cx.update_global::<SettingsStore, _>(|store, cx| {
9071            store.update_user_settings(cx, |settings| {
9072                settings.project_panel.get_or_insert_default().sort_mode =
9073                    Some(settings::ProjectPanelSortMode::Mixed);
9074            });
9075        });
9076    });
9077    cx.run_until_parked();
9078
9079    assert_eq!(
9080        visible_entries_as_strings(&panel, 0..50, cx),
9081        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9082    );
9083
9084    // Toggle back to DirectoriesFirst
9085    cx.update(|_, cx| {
9086        cx.update_global::<SettingsStore, _>(|store, cx| {
9087            store.update_user_settings(cx, |settings| {
9088                settings.project_panel.get_or_insert_default().sort_mode =
9089                    Some(settings::ProjectPanelSortMode::DirectoriesFirst);
9090            });
9091        });
9092    });
9093    cx.run_until_parked();
9094
9095    assert_eq!(
9096        visible_entries_as_strings(&panel, 0..50, cx),
9097        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9098    );
9099}
9100
9101#[gpui::test]
9102async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
9103    cx: &mut gpui::TestAppContext,
9104) {
9105    init_test(cx);
9106
9107    // parent: accept
9108    run_create_file_in_folded_path_case(
9109        "parent",
9110        "root1/parent",
9111        "file_in_parent.txt",
9112        &[
9113            "v root1",
9114            "    v parent",
9115            "        > subdir/child",
9116            "          [EDITOR: '']  <== selected",
9117        ],
9118        &[
9119            "v root1",
9120            "    v parent",
9121            "        > subdir/child",
9122            "          file_in_parent.txt  <== selected  <== marked",
9123        ],
9124        true,
9125        cx,
9126    )
9127    .await;
9128
9129    // parent: cancel
9130    run_create_file_in_folded_path_case(
9131        "parent",
9132        "root1/parent",
9133        "file_in_parent.txt",
9134        &[
9135            "v root1",
9136            "    v parent",
9137            "        > subdir/child",
9138            "          [EDITOR: '']  <== selected",
9139        ],
9140        &["v root1", "    > parent/subdir/child  <== selected"],
9141        false,
9142        cx,
9143    )
9144    .await;
9145
9146    // subdir: accept
9147    run_create_file_in_folded_path_case(
9148        "subdir",
9149        "root1/parent/subdir",
9150        "file_in_subdir.txt",
9151        &[
9152            "v root1",
9153            "    v parent/subdir",
9154            "        > child",
9155            "          [EDITOR: '']  <== selected",
9156        ],
9157        &[
9158            "v root1",
9159            "    v parent/subdir",
9160            "        > child",
9161            "          file_in_subdir.txt  <== selected  <== marked",
9162        ],
9163        true,
9164        cx,
9165    )
9166    .await;
9167
9168    // subdir: cancel
9169    run_create_file_in_folded_path_case(
9170        "subdir",
9171        "root1/parent/subdir",
9172        "file_in_subdir.txt",
9173        &[
9174            "v root1",
9175            "    v parent/subdir",
9176            "        > child",
9177            "          [EDITOR: '']  <== selected",
9178        ],
9179        &["v root1", "    > parent/subdir/child  <== selected"],
9180        false,
9181        cx,
9182    )
9183    .await;
9184
9185    // child: accept
9186    run_create_file_in_folded_path_case(
9187        "child",
9188        "root1/parent/subdir/child",
9189        "file_in_child.txt",
9190        &[
9191            "v root1",
9192            "    v parent/subdir/child",
9193            "          [EDITOR: '']  <== selected",
9194        ],
9195        &[
9196            "v root1",
9197            "    v parent/subdir/child",
9198            "          file_in_child.txt  <== selected  <== marked",
9199        ],
9200        true,
9201        cx,
9202    )
9203    .await;
9204
9205    // child: cancel
9206    run_create_file_in_folded_path_case(
9207        "child",
9208        "root1/parent/subdir/child",
9209        "file_in_child.txt",
9210        &[
9211            "v root1",
9212            "    v parent/subdir/child",
9213            "          [EDITOR: '']  <== selected",
9214        ],
9215        &["v root1", "    v parent/subdir/child  <== selected"],
9216        false,
9217        cx,
9218    )
9219    .await;
9220}
9221
9222#[gpui::test]
9223async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
9224    cx: &mut gpui::TestAppContext,
9225) {
9226    init_test(cx);
9227
9228    let fs = FakeFs::new(cx.executor());
9229    fs.insert_tree(
9230        "/root1",
9231        json!({
9232            "parent": {
9233                "subdir": {
9234                    "child": {},
9235                }
9236            }
9237        }),
9238    )
9239    .await;
9240
9241    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9242    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9243    let workspace = window
9244        .read_with(cx, |mw, _| mw.workspace().clone())
9245        .unwrap();
9246    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9247
9248    let panel = workspace.update_in(cx, |workspace, window, cx| {
9249        let panel = ProjectPanel::new(workspace, window, cx);
9250        workspace.add_panel(panel.clone(), window, cx);
9251        panel
9252    });
9253
9254    cx.update(|_, cx| {
9255        let settings = *ProjectPanelSettings::get_global(cx);
9256        ProjectPanelSettings::override_global(
9257            ProjectPanelSettings {
9258                auto_fold_dirs: true,
9259                ..settings
9260            },
9261            cx,
9262        );
9263    });
9264
9265    panel.update_in(cx, |panel, window, cx| {
9266        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9267    });
9268    cx.run_until_parked();
9269
9270    select_folded_path_with_mark(
9271        &panel,
9272        "root1/parent/subdir/child",
9273        "root1/parent/subdir",
9274        cx,
9275    );
9276    panel.update(cx, |panel, _| {
9277        panel.marked_entries.clear();
9278    });
9279
9280    let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
9281        .expect("parent directory should exist for this test");
9282    let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
9283        .expect("subdir directory should exist for this test");
9284    let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
9285        .expect("child directory should exist for this test");
9286
9287    panel.update(cx, |panel, _| {
9288        let selection = panel
9289            .selection
9290            .expect("leaf directory should be selected before creating a new entry");
9291        assert_eq!(
9292            selection.entry_id, child_entry_id,
9293            "initial selection should be the folded leaf entry"
9294        );
9295        assert_eq!(
9296            panel.resolve_entry(selection.entry_id),
9297            subdir_entry_id,
9298            "active folded component should start at subdir"
9299        );
9300    });
9301
9302    panel.update_in(cx, |panel, window, cx| {
9303        panel.deploy_context_menu(
9304            gpui::point(gpui::px(1.), gpui::px(1.)),
9305            child_entry_id,
9306            window,
9307            cx,
9308        );
9309        panel.new_file(&NewFile, window, cx);
9310    });
9311    cx.run_until_parked();
9312    panel.update_in(cx, |panel, window, cx| {
9313        assert!(panel.filename_editor.read(cx).is_focused(window));
9314    });
9315    cx.run_until_parked();
9316
9317    set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
9318
9319    panel.update_in(cx, |panel, window, cx| {
9320        panel.deploy_context_menu(
9321            gpui::point(gpui::px(2.), gpui::px(2.)),
9322            subdir_entry_id,
9323            window,
9324            cx,
9325        );
9326    });
9327    cx.run_until_parked();
9328
9329    panel.update(cx, |panel, _| {
9330        assert!(
9331            panel.state.edit_state.is_none(),
9332            "opening another context menu should blur the filename editor and discard edit state"
9333        );
9334        let selection = panel
9335            .selection
9336            .expect("selection should restore to the previously focused leaf entry");
9337        assert_eq!(
9338            selection.entry_id, child_entry_id,
9339            "blur-driven cancellation should restore the previous leaf selection"
9340        );
9341        assert_eq!(
9342            panel.resolve_entry(selection.entry_id),
9343            parent_entry_id,
9344            "temporary unfolded pending state should preserve the active ancestor chosen before blur"
9345        );
9346    });
9347
9348    panel.update_in(cx, |panel, window, cx| {
9349        panel.new_file(&NewFile, window, cx);
9350    });
9351    cx.run_until_parked();
9352    assert_eq!(
9353        visible_entries_as_strings(&panel, 0..10, cx),
9354        &[
9355            "v root1",
9356            "    v parent",
9357            "        > subdir/child",
9358            "          [EDITOR: '']  <== selected",
9359        ],
9360        "new file after blur should use the preserved active ancestor"
9361    );
9362    panel.update(cx, |panel, _| {
9363        let edit_state = panel
9364            .state
9365            .edit_state
9366            .as_ref()
9367            .expect("new file should enter edit state");
9368        assert_eq!(
9369            edit_state.temporarily_unfolded,
9370            Some(parent_entry_id),
9371            "temporary unfolding should now target parent after restoring the active ancestor"
9372        );
9373    });
9374
9375    let file_name = "created_after_blur.txt";
9376    panel
9377        .update_in(cx, |panel, window, cx| {
9378            panel.filename_editor.update(cx, |editor, cx| {
9379                editor.set_text(file_name, window, cx);
9380            });
9381            panel.confirm_edit(true, window, cx).expect(
9382                "confirm_edit should start creation for the file created after blur transition",
9383            )
9384        })
9385        .await
9386        .expect("creating file after blur transition should succeed");
9387    cx.run_until_parked();
9388
9389    assert!(
9390        fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
9391            .await,
9392        "file should be created under parent after active ancestor is restored to parent"
9393    );
9394    assert!(
9395        !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
9396            .await,
9397        "file should not be created under subdir when parent is the active ancestor"
9398    );
9399}
9400
9401async fn run_create_file_in_folded_path_case(
9402    case_name: &str,
9403    active_ancestor_path: &str,
9404    created_file_name: &str,
9405    expected_temporary_state: &[&str],
9406    expected_final_state: &[&str],
9407    accept_creation: bool,
9408    cx: &mut gpui::TestAppContext,
9409) {
9410    let expected_collapsed_state = &["v root1", "    > parent/subdir/child  <== selected"];
9411
9412    let fs = FakeFs::new(cx.executor());
9413    fs.insert_tree(
9414        "/root1",
9415        json!({
9416            "parent": {
9417                "subdir": {
9418                    "child": {},
9419                }
9420            }
9421        }),
9422    )
9423    .await;
9424
9425    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9426    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9427    let workspace = window
9428        .read_with(cx, |mw, _| mw.workspace().clone())
9429        .unwrap();
9430    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9431
9432    let panel = workspace.update_in(cx, |workspace, window, cx| {
9433        let panel = ProjectPanel::new(workspace, window, cx);
9434        workspace.add_panel(panel.clone(), window, cx);
9435        panel
9436    });
9437
9438    cx.update(|_, cx| {
9439        let settings = *ProjectPanelSettings::get_global(cx);
9440        ProjectPanelSettings::override_global(
9441            ProjectPanelSettings {
9442                auto_fold_dirs: true,
9443                ..settings
9444            },
9445            cx,
9446        );
9447    });
9448
9449    panel.update_in(cx, |panel, window, cx| {
9450        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9451    });
9452    cx.run_until_parked();
9453
9454    select_folded_path_with_mark(
9455        &panel,
9456        "root1/parent/subdir/child",
9457        active_ancestor_path,
9458        cx,
9459    );
9460    panel.update(cx, |panel, _| {
9461        panel.marked_entries.clear();
9462    });
9463
9464    assert_eq!(
9465        visible_entries_as_strings(&panel, 0..10, cx),
9466        expected_collapsed_state,
9467        "case '{}' should start from a folded state",
9468        case_name
9469    );
9470
9471    panel.update_in(cx, |panel, window, cx| {
9472        panel.new_file(&NewFile, window, cx);
9473    });
9474    cx.run_until_parked();
9475    panel.update_in(cx, |panel, window, cx| {
9476        assert!(panel.filename_editor.read(cx).is_focused(window));
9477    });
9478    cx.run_until_parked();
9479    assert_eq!(
9480        visible_entries_as_strings(&panel, 0..10, cx),
9481        expected_temporary_state,
9482        "case '{}' ({}) should temporarily unfold the active ancestor while editing",
9483        case_name,
9484        if accept_creation { "accept" } else { "cancel" }
9485    );
9486
9487    let relative_directory = active_ancestor_path
9488        .strip_prefix("root1/")
9489        .expect("active_ancestor_path should start with root1/");
9490    let created_file_path = PathBuf::from("/root1")
9491        .join(relative_directory)
9492        .join(created_file_name);
9493
9494    if accept_creation {
9495        panel
9496            .update_in(cx, |panel, window, cx| {
9497                panel.filename_editor.update(cx, |editor, cx| {
9498                    editor.set_text(created_file_name, window, cx);
9499                });
9500                panel.confirm_edit(true, window, cx).unwrap()
9501            })
9502            .await
9503            .unwrap();
9504        cx.run_until_parked();
9505
9506        assert_eq!(
9507            visible_entries_as_strings(&panel, 0..10, cx),
9508            expected_final_state,
9509            "case '{}' should keep the newly created file selected and marked after accept",
9510            case_name
9511        );
9512        assert!(
9513            fs.is_file(created_file_path.as_path()).await,
9514            "case '{}' should create file '{}'",
9515            case_name,
9516            created_file_path.display()
9517        );
9518    } else {
9519        panel.update_in(cx, |panel, window, cx| {
9520            panel.cancel(&Cancel, window, cx);
9521        });
9522        cx.run_until_parked();
9523
9524        assert_eq!(
9525            visible_entries_as_strings(&panel, 0..10, cx),
9526            expected_final_state,
9527            "case '{}' should keep the expected panel state after cancel",
9528            case_name
9529        );
9530        assert!(
9531            !fs.is_file(created_file_path.as_path()).await,
9532            "case '{}' should not create a file after cancel",
9533            case_name
9534        );
9535    }
9536}
9537
9538fn init_test(cx: &mut TestAppContext) {
9539    cx.update(|cx| {
9540        let settings_store = SettingsStore::test(cx);
9541        cx.set_global(settings_store);
9542        theme::init(theme::LoadThemes::JustBase, cx);
9543        crate::init(cx);
9544
9545        cx.update_global::<SettingsStore, _>(|store, cx| {
9546            store.update_user_settings(cx, |settings| {
9547                settings
9548                    .project_panel
9549                    .get_or_insert_default()
9550                    .auto_fold_dirs = Some(false);
9551                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
9552            });
9553        });
9554    });
9555}
9556
9557fn init_test_with_editor(cx: &mut TestAppContext) {
9558    cx.update(|cx| {
9559        let app_state = AppState::test(cx);
9560        theme::init(theme::LoadThemes::JustBase, cx);
9561        editor::init(cx);
9562        crate::init(cx);
9563        workspace::init(app_state, cx);
9564
9565        cx.update_global::<SettingsStore, _>(|store, cx| {
9566            store.update_user_settings(cx, |settings| {
9567                settings
9568                    .project_panel
9569                    .get_or_insert_default()
9570                    .auto_fold_dirs = Some(false);
9571                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
9572            });
9573        });
9574    });
9575}
9576
9577fn set_auto_open_settings(
9578    cx: &mut TestAppContext,
9579    auto_open_settings: ProjectPanelAutoOpenSettings,
9580) {
9581    cx.update(|cx| {
9582        cx.update_global::<SettingsStore, _>(|store, cx| {
9583            store.update_user_settings(cx, |settings| {
9584                settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
9585            });
9586        })
9587    });
9588}
9589
9590fn ensure_single_file_is_opened(
9591    workspace: &Entity<Workspace>,
9592    expected_path: &str,
9593    cx: &mut VisualTestContext,
9594) {
9595    workspace.update_in(cx, |workspace, _, cx| {
9596        let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9597        assert_eq!(worktrees.len(), 1);
9598        let worktree_id = worktrees[0].read(cx).id();
9599
9600        let open_project_paths = workspace
9601            .panes()
9602            .iter()
9603            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9604            .collect::<Vec<_>>();
9605        assert_eq!(
9606            open_project_paths,
9607            vec![ProjectPath {
9608                worktree_id,
9609                path: Arc::from(rel_path(expected_path))
9610            }],
9611            "Should have opened file, selected in project panel"
9612        );
9613    });
9614}
9615
9616fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9617    assert!(
9618        !cx.has_pending_prompt(),
9619        "Should have no prompts before the deletion"
9620    );
9621    panel.update_in(cx, |panel, window, cx| {
9622        panel.delete(&Delete { skip_prompt: false }, window, cx)
9623    });
9624    assert!(
9625        cx.has_pending_prompt(),
9626        "Should have a prompt after the deletion"
9627    );
9628    cx.simulate_prompt_answer("Delete");
9629    assert!(
9630        !cx.has_pending_prompt(),
9631        "Should have no prompts after prompt was replied to"
9632    );
9633    cx.executor().run_until_parked();
9634}
9635
9636fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9637    assert!(
9638        !cx.has_pending_prompt(),
9639        "Should have no prompts before the deletion"
9640    );
9641    panel.update_in(cx, |panel, window, cx| {
9642        panel.delete(&Delete { skip_prompt: true }, window, cx)
9643    });
9644    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9645    cx.executor().run_until_parked();
9646}
9647
9648fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
9649    assert!(
9650        !cx.has_pending_prompt(),
9651        "Should have no prompts after deletion operation closes the file"
9652    );
9653    workspace.update_in(cx, |workspace, _window, cx| {
9654        let open_project_paths = workspace
9655            .panes()
9656            .iter()
9657            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9658            .collect::<Vec<_>>();
9659        assert!(
9660            open_project_paths.is_empty(),
9661            "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9662        );
9663    });
9664}
9665
9666struct TestProjectItemView {
9667    focus_handle: FocusHandle,
9668    path: ProjectPath,
9669}
9670
9671struct TestProjectItem {
9672    path: ProjectPath,
9673}
9674
9675impl project::ProjectItem for TestProjectItem {
9676    fn try_open(
9677        _project: &Entity<Project>,
9678        path: &ProjectPath,
9679        cx: &mut App,
9680    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
9681        let path = path.clone();
9682        Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
9683    }
9684
9685    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9686        None
9687    }
9688
9689    fn project_path(&self, _: &App) -> Option<ProjectPath> {
9690        Some(self.path.clone())
9691    }
9692
9693    fn is_dirty(&self) -> bool {
9694        false
9695    }
9696}
9697
9698impl ProjectItem for TestProjectItemView {
9699    type Item = TestProjectItem;
9700
9701    fn for_project_item(
9702        _: Entity<Project>,
9703        _: Option<&Pane>,
9704        project_item: Entity<Self::Item>,
9705        _: &mut Window,
9706        cx: &mut Context<Self>,
9707    ) -> Self
9708    where
9709        Self: Sized,
9710    {
9711        Self {
9712            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9713            focus_handle: cx.focus_handle(),
9714        }
9715    }
9716}
9717
9718impl Item for TestProjectItemView {
9719    type Event = ();
9720
9721    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
9722        "Test".into()
9723    }
9724}
9725
9726impl EventEmitter<()> for TestProjectItemView {}
9727
9728impl Focusable for TestProjectItemView {
9729    fn focus_handle(&self, _: &App) -> FocusHandle {
9730        self.focus_handle.clone()
9731    }
9732}
9733
9734impl Render for TestProjectItemView {
9735    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9736        Empty
9737    }
9738}