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_dragging_same_named_files_preserves_one_source_on_conflict(
4417    cx: &mut gpui::TestAppContext,
4418) {
4419    init_test(cx);
4420
4421    let fs = FakeFs::new(cx.executor());
4422    fs.insert_tree(
4423        "/root",
4424        json!({
4425            "dir_a": {
4426                "shared.txt": "from a"
4427            },
4428            "dir_b": {
4429                "shared.txt": "from b"
4430            }
4431        }),
4432    )
4433    .await;
4434
4435    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4436    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4437    let workspace = window
4438        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
4439        .unwrap();
4440    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4441    let panel = workspace.update_in(cx, ProjectPanel::new);
4442    cx.run_until_parked();
4443
4444    panel.update_in(cx, |panel, window, cx| {
4445        let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = {
4446            let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap();
4447            let worktree = worktree.read(cx);
4448            let root_entry_id = worktree.root_entry().unwrap().id;
4449            let worktree_id = worktree.id();
4450            let entry_a_id = worktree
4451                .entry_for_path(rel_path("dir_a/shared.txt"))
4452                .unwrap()
4453                .id;
4454            let entry_b_id = worktree
4455                .entry_for_path(rel_path("dir_b/shared.txt"))
4456                .unwrap()
4457                .id;
4458            (root_entry_id, worktree_id, entry_a_id, entry_b_id)
4459        };
4460
4461        let drag = DraggedSelection {
4462            active_selection: SelectedEntry {
4463                worktree_id,
4464                entry_id: entry_a_id,
4465            },
4466            marked_selections: Arc::new([
4467                SelectedEntry {
4468                    worktree_id,
4469                    entry_id: entry_a_id,
4470                },
4471                SelectedEntry {
4472                    worktree_id,
4473                    entry_id: entry_b_id,
4474                },
4475            ]),
4476        };
4477
4478        panel.drag_onto(&drag, root_entry_id, false, window, cx);
4479    });
4480    cx.executor().run_until_parked();
4481
4482    let files = fs.files();
4483    assert!(files.contains(&PathBuf::from(path!("/root/shared.txt"))));
4484
4485    let remaining_sources = [
4486        PathBuf::from(path!("/root/dir_a/shared.txt")),
4487        PathBuf::from(path!("/root/dir_b/shared.txt")),
4488    ]
4489    .into_iter()
4490    .filter(|path| files.contains(path))
4491    .count();
4492
4493    assert_eq!(
4494        remaining_sources, 1,
4495        "one conflicting source file should remain in place"
4496    );
4497}
4498
4499#[gpui::test]
4500async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4501    init_test(cx);
4502
4503    let fs = FakeFs::new(cx.executor());
4504    fs.insert_tree(
4505        "/root_a",
4506        json!({
4507            "src": {
4508                "lib.rs": "",
4509                "main.rs": ""
4510            },
4511            "docs": {
4512                "guide.md": ""
4513            },
4514            "multi": {
4515                "alpha.txt": "",
4516                "beta.txt": ""
4517            }
4518        }),
4519    )
4520    .await;
4521    fs.insert_tree(
4522        "/root_b",
4523        json!({
4524            "dst": {
4525                "existing.md": ""
4526            },
4527            "target.txt": ""
4528        }),
4529    )
4530    .await;
4531
4532    let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4533    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4534    let workspace = window
4535        .read_with(cx, |mw, _| mw.workspace().clone())
4536        .unwrap();
4537    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4538    let panel = workspace.update_in(cx, ProjectPanel::new);
4539    cx.run_until_parked();
4540
4541    // Case 1: move a file onto a directory in another worktree.
4542    select_path(&panel, "root_a/src/main.rs", cx);
4543    drag_selection_to(&panel, "root_b/dst", false, cx);
4544    assert!(
4545        find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4546        "Dragged file should appear under destination worktree"
4547    );
4548    assert_eq!(
4549        find_project_entry(&panel, "root_a/src/main.rs", cx),
4550        None,
4551        "Dragged file should be removed from the source worktree"
4552    );
4553
4554    // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4555    select_path(&panel, "root_a/docs/guide.md", cx);
4556    drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4557    assert!(
4558        find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4559        "Dropping onto a file should place the entry beside the target file"
4560    );
4561    assert_eq!(
4562        find_project_entry(&panel, "root_a/docs/guide.md", cx),
4563        None,
4564        "Source file should be removed after the move"
4565    );
4566
4567    // Case 3: move an entire directory.
4568    select_path(&panel, "root_a/src", cx);
4569    drag_selection_to(&panel, "root_b/dst", false, cx);
4570    assert!(
4571        find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4572        "Dragging a directory should move its nested contents"
4573    );
4574    assert_eq!(
4575        find_project_entry(&panel, "root_a/src", cx),
4576        None,
4577        "Directory should no longer exist in the source worktree"
4578    );
4579
4580    // Case 4: multi-selection drag between worktrees.
4581    panel.update(cx, |panel, _| panel.marked_entries.clear());
4582    select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4583    select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4584    drag_selection_to(&panel, "root_b/dst", false, cx);
4585    assert!(
4586        find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4587            && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4588        "All marked entries should move to the destination worktree"
4589    );
4590    assert_eq!(
4591        find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4592        None,
4593        "Marked entries should be removed from the origin worktree"
4594    );
4595    assert_eq!(
4596        find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4597        None,
4598        "Marked entries should be removed from the origin worktree"
4599    );
4600}
4601
4602#[gpui::test]
4603async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4604    init_test(cx);
4605
4606    let fs = FakeFs::new(cx.executor());
4607    fs.insert_tree(
4608        "/root",
4609        json!({
4610            "src": {
4611                "folder1": {
4612                    "mod.rs": "// folder1 mod"
4613                },
4614                "folder2": {
4615                    "mod.rs": "// folder2 mod"
4616                },
4617                "folder3": {
4618                    "mod.rs": "// folder3 mod",
4619                    "helper.rs": "// helper"
4620                },
4621                "main.rs": ""
4622            }
4623        }),
4624    )
4625    .await;
4626
4627    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4628    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4629    let workspace = window
4630        .read_with(cx, |mw, _| mw.workspace().clone())
4631        .unwrap();
4632    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4633    let panel = workspace.update_in(cx, ProjectPanel::new);
4634    cx.run_until_parked();
4635
4636    toggle_expand_dir(&panel, "root/src", cx);
4637    toggle_expand_dir(&panel, "root/src/folder1", cx);
4638    toggle_expand_dir(&panel, "root/src/folder2", cx);
4639    toggle_expand_dir(&panel, "root/src/folder3", cx);
4640    cx.run_until_parked();
4641
4642    // Case 1: Dragging a folder and a file from a sibling folder together.
4643    panel.update(cx, |panel, _| panel.marked_entries.clear());
4644    select_path_with_mark(&panel, "root/src/folder1", cx);
4645    select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4646
4647    drag_selection_to(&panel, "root", false, cx);
4648
4649    assert!(
4650        find_project_entry(&panel, "root/folder1", cx).is_some(),
4651        "folder1 should be at root after drag"
4652    );
4653    assert!(
4654        find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4655        "folder1/mod.rs should still be inside folder1 after drag"
4656    );
4657    assert_eq!(
4658        find_project_entry(&panel, "root/src/folder1", cx),
4659        None,
4660        "folder1 should no longer be in src"
4661    );
4662    assert!(
4663        find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4664        "mod.rs from folder2 should be at root"
4665    );
4666
4667    // Case 2: Dragging a folder and its own child together.
4668    panel.update(cx, |panel, _| panel.marked_entries.clear());
4669    select_path_with_mark(&panel, "root/src/folder3", cx);
4670    select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4671
4672    drag_selection_to(&panel, "root", false, cx);
4673
4674    assert!(
4675        find_project_entry(&panel, "root/folder3", cx).is_some(),
4676        "folder3 should be at root after drag"
4677    );
4678    assert!(
4679        find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4680        "folder3/mod.rs should still be inside folder3"
4681    );
4682    assert!(
4683        find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4684        "folder3/helper.rs should still be inside folder3"
4685    );
4686}
4687
4688#[gpui::test]
4689async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4690    init_test_with_editor(cx);
4691    cx.update(|cx| {
4692        cx.update_global::<SettingsStore, _>(|store, cx| {
4693            store.update_user_settings(cx, |settings| {
4694                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4695                settings
4696                    .project_panel
4697                    .get_or_insert_default()
4698                    .auto_reveal_entries = Some(false);
4699            });
4700        })
4701    });
4702
4703    let fs = FakeFs::new(cx.background_executor.clone());
4704    fs.insert_tree(
4705        "/project_root",
4706        json!({
4707            ".git": {},
4708            ".gitignore": "**/gitignored_dir",
4709            "dir_1": {
4710                "file_1.py": "# File 1_1 contents",
4711                "file_2.py": "# File 1_2 contents",
4712                "file_3.py": "# File 1_3 contents",
4713                "gitignored_dir": {
4714                    "file_a.py": "# File contents",
4715                    "file_b.py": "# File contents",
4716                    "file_c.py": "# File contents",
4717                },
4718            },
4719            "dir_2": {
4720                "file_1.py": "# File 2_1 contents",
4721                "file_2.py": "# File 2_2 contents",
4722                "file_3.py": "# File 2_3 contents",
4723            }
4724        }),
4725    )
4726    .await;
4727
4728    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4729    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4730    let workspace = window
4731        .read_with(cx, |mw, _| mw.workspace().clone())
4732        .unwrap();
4733    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4734    let panel = workspace.update_in(cx, ProjectPanel::new);
4735    cx.run_until_parked();
4736
4737    assert_eq!(
4738        visible_entries_as_strings(&panel, 0..20, cx),
4739        &[
4740            "v project_root",
4741            "    > .git",
4742            "    > dir_1",
4743            "    > dir_2",
4744            "      .gitignore",
4745        ]
4746    );
4747
4748    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4749        .expect("dir 1 file is not ignored and should have an entry");
4750    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4751        .expect("dir 2 file is not ignored and should have an entry");
4752    let gitignored_dir_file =
4753        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4754    assert_eq!(
4755        gitignored_dir_file, None,
4756        "File in the gitignored dir should not have an entry before its dir is toggled"
4757    );
4758
4759    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4760    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4761    cx.executor().run_until_parked();
4762    assert_eq!(
4763        visible_entries_as_strings(&panel, 0..20, cx),
4764        &[
4765            "v project_root",
4766            "    > .git",
4767            "    v dir_1",
4768            "        v gitignored_dir  <== selected",
4769            "              file_a.py",
4770            "              file_b.py",
4771            "              file_c.py",
4772            "          file_1.py",
4773            "          file_2.py",
4774            "          file_3.py",
4775            "    > dir_2",
4776            "      .gitignore",
4777        ],
4778        "Should show gitignored dir file list in the project panel"
4779    );
4780    let gitignored_dir_file =
4781        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4782            .expect("after gitignored dir got opened, a file entry should be present");
4783
4784    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4785    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4786    assert_eq!(
4787        visible_entries_as_strings(&panel, 0..20, cx),
4788        &[
4789            "v project_root",
4790            "    > .git",
4791            "    > dir_1  <== selected",
4792            "    > dir_2",
4793            "      .gitignore",
4794        ],
4795        "Should hide all dir contents again and prepare for the auto reveal test"
4796    );
4797
4798    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4799        panel.update(cx, |panel, cx| {
4800            panel.project.update(cx, |_, cx| {
4801                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4802            })
4803        });
4804        cx.run_until_parked();
4805        assert_eq!(
4806            visible_entries_as_strings(&panel, 0..20, cx),
4807            &[
4808                "v project_root",
4809                "    > .git",
4810                "    > dir_1  <== selected",
4811                "    > dir_2",
4812                "      .gitignore",
4813            ],
4814            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4815        );
4816    }
4817
4818    cx.update(|_, cx| {
4819        cx.update_global::<SettingsStore, _>(|store, cx| {
4820            store.update_user_settings(cx, |settings| {
4821                settings
4822                    .project_panel
4823                    .get_or_insert_default()
4824                    .auto_reveal_entries = Some(true)
4825            });
4826        })
4827    });
4828
4829    panel.update(cx, |panel, cx| {
4830        panel.project.update(cx, |_, cx| {
4831            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4832        })
4833    });
4834    cx.run_until_parked();
4835    assert_eq!(
4836        visible_entries_as_strings(&panel, 0..20, cx),
4837        &[
4838            "v project_root",
4839            "    > .git",
4840            "    v dir_1",
4841            "        > gitignored_dir",
4842            "          file_1.py  <== selected  <== marked",
4843            "          file_2.py",
4844            "          file_3.py",
4845            "    > dir_2",
4846            "      .gitignore",
4847        ],
4848        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4849    );
4850
4851    panel.update(cx, |panel, cx| {
4852        panel.project.update(cx, |_, cx| {
4853            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4854        })
4855    });
4856    cx.run_until_parked();
4857    assert_eq!(
4858        visible_entries_as_strings(&panel, 0..20, cx),
4859        &[
4860            "v project_root",
4861            "    > .git",
4862            "    v dir_1",
4863            "        > gitignored_dir",
4864            "          file_1.py",
4865            "          file_2.py",
4866            "          file_3.py",
4867            "    v dir_2",
4868            "          file_1.py  <== selected  <== marked",
4869            "          file_2.py",
4870            "          file_3.py",
4871            "      .gitignore",
4872        ],
4873        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4874    );
4875
4876    panel.update(cx, |panel, cx| {
4877        panel.project.update(cx, |_, cx| {
4878            cx.emit(project::Event::ActiveEntryChanged(Some(
4879                gitignored_dir_file,
4880            )))
4881        })
4882    });
4883    cx.run_until_parked();
4884    assert_eq!(
4885        visible_entries_as_strings(&panel, 0..20, cx),
4886        &[
4887            "v project_root",
4888            "    > .git",
4889            "    v dir_1",
4890            "        > gitignored_dir",
4891            "          file_1.py",
4892            "          file_2.py",
4893            "          file_3.py",
4894            "    v dir_2",
4895            "          file_1.py  <== selected  <== marked",
4896            "          file_2.py",
4897            "          file_3.py",
4898            "      .gitignore",
4899        ],
4900        "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4901    );
4902
4903    panel.update(cx, |panel, cx| {
4904        panel.project.update(cx, |_, cx| {
4905            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4906        })
4907    });
4908    cx.run_until_parked();
4909    assert_eq!(
4910        visible_entries_as_strings(&panel, 0..20, cx),
4911        &[
4912            "v project_root",
4913            "    > .git",
4914            "    v dir_1",
4915            "        v gitignored_dir",
4916            "              file_a.py  <== selected  <== marked",
4917            "              file_b.py",
4918            "              file_c.py",
4919            "          file_1.py",
4920            "          file_2.py",
4921            "          file_3.py",
4922            "    v dir_2",
4923            "          file_1.py",
4924            "          file_2.py",
4925            "          file_3.py",
4926            "      .gitignore",
4927        ],
4928        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4929    );
4930
4931    panel.update(cx, |panel, cx| {
4932        panel.project.update(cx, |_, cx| {
4933            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4934        })
4935    });
4936    cx.run_until_parked();
4937    assert_eq!(
4938        visible_entries_as_strings(&panel, 0..20, cx),
4939        &[
4940            "v project_root",
4941            "    > .git",
4942            "    v dir_1",
4943            "        v gitignored_dir",
4944            "              file_a.py",
4945            "              file_b.py",
4946            "              file_c.py",
4947            "          file_1.py",
4948            "          file_2.py",
4949            "          file_3.py",
4950            "    v dir_2",
4951            "          file_1.py  <== selected  <== marked",
4952            "          file_2.py",
4953            "          file_3.py",
4954            "      .gitignore",
4955        ],
4956        "After switching to dir_2_file, it should be selected and marked"
4957    );
4958
4959    panel.update(cx, |panel, cx| {
4960        panel.project.update(cx, |_, cx| {
4961            cx.emit(project::Event::ActiveEntryChanged(Some(
4962                gitignored_dir_file,
4963            )))
4964        })
4965    });
4966    cx.run_until_parked();
4967    assert_eq!(
4968        visible_entries_as_strings(&panel, 0..20, cx),
4969        &[
4970            "v project_root",
4971            "    > .git",
4972            "    v dir_1",
4973            "        v gitignored_dir",
4974            "              file_a.py  <== selected  <== marked",
4975            "              file_b.py",
4976            "              file_c.py",
4977            "          file_1.py",
4978            "          file_2.py",
4979            "          file_3.py",
4980            "    v dir_2",
4981            "          file_1.py",
4982            "          file_2.py",
4983            "          file_3.py",
4984            "      .gitignore",
4985        ],
4986        "When a gitignored entry is already visible, auto reveal should mark it as selected"
4987    );
4988}
4989
4990#[gpui::test]
4991async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
4992    init_test_with_editor(cx);
4993    cx.update(|cx| {
4994        cx.update_global::<SettingsStore, _>(|store, cx| {
4995            store.update_user_settings(cx, |settings| {
4996                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4997                settings.project.worktree.file_scan_inclusions =
4998                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
4999                settings
5000                    .project_panel
5001                    .get_or_insert_default()
5002                    .auto_reveal_entries = Some(false)
5003            });
5004        })
5005    });
5006
5007    let fs = FakeFs::new(cx.background_executor.clone());
5008    fs.insert_tree(
5009        "/project_root",
5010        json!({
5011            ".git": {},
5012            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
5013            "dir_1": {
5014                "file_1.py": "# File 1_1 contents",
5015                "file_2.py": "# File 1_2 contents",
5016                "file_3.py": "# File 1_3 contents",
5017                "gitignored_dir": {
5018                    "file_a.py": "# File contents",
5019                    "file_b.py": "# File contents",
5020                    "file_c.py": "# File contents",
5021                },
5022            },
5023            "dir_2": {
5024                "file_1.py": "# File 2_1 contents",
5025                "file_2.py": "# File 2_2 contents",
5026                "file_3.py": "# File 2_3 contents",
5027            },
5028            "always_included_but_ignored_dir": {
5029                "file_a.py": "# File contents",
5030                "file_b.py": "# File contents",
5031                "file_c.py": "# File contents",
5032            },
5033        }),
5034    )
5035    .await;
5036
5037    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5038    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5039    let workspace = window
5040        .read_with(cx, |mw, _| mw.workspace().clone())
5041        .unwrap();
5042    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5043    let panel = workspace.update_in(cx, ProjectPanel::new);
5044    cx.run_until_parked();
5045
5046    assert_eq!(
5047        visible_entries_as_strings(&panel, 0..20, cx),
5048        &[
5049            "v project_root",
5050            "    > .git",
5051            "    > always_included_but_ignored_dir",
5052            "    > dir_1",
5053            "    > dir_2",
5054            "      .gitignore",
5055        ]
5056    );
5057
5058    let gitignored_dir_file =
5059        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5060    let always_included_but_ignored_dir_file = find_project_entry(
5061        &panel,
5062        "project_root/always_included_but_ignored_dir/file_a.py",
5063        cx,
5064    )
5065    .expect("file that is .gitignored but set to always be included should have an entry");
5066    assert_eq!(
5067        gitignored_dir_file, None,
5068        "File in the gitignored dir should not have an entry unless its directory is toggled"
5069    );
5070
5071    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5072    cx.run_until_parked();
5073    cx.update(|_, cx| {
5074        cx.update_global::<SettingsStore, _>(|store, cx| {
5075            store.update_user_settings(cx, |settings| {
5076                settings
5077                    .project_panel
5078                    .get_or_insert_default()
5079                    .auto_reveal_entries = Some(true)
5080            });
5081        })
5082    });
5083
5084    panel.update(cx, |panel, cx| {
5085        panel.project.update(cx, |_, cx| {
5086            cx.emit(project::Event::ActiveEntryChanged(Some(
5087                always_included_but_ignored_dir_file,
5088            )))
5089        })
5090    });
5091    cx.run_until_parked();
5092
5093    assert_eq!(
5094        visible_entries_as_strings(&panel, 0..20, cx),
5095        &[
5096            "v project_root",
5097            "    > .git",
5098            "    v always_included_but_ignored_dir",
5099            "          file_a.py  <== selected  <== marked",
5100            "          file_b.py",
5101            "          file_c.py",
5102            "    v dir_1",
5103            "        > gitignored_dir",
5104            "          file_1.py",
5105            "          file_2.py",
5106            "          file_3.py",
5107            "    > dir_2",
5108            "      .gitignore",
5109        ],
5110        "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
5111    );
5112}
5113
5114#[gpui::test]
5115async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5116    init_test_with_editor(cx);
5117    cx.update(|cx| {
5118        cx.update_global::<SettingsStore, _>(|store, cx| {
5119            store.update_user_settings(cx, |settings| {
5120                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5121                settings
5122                    .project_panel
5123                    .get_or_insert_default()
5124                    .auto_reveal_entries = Some(false)
5125            });
5126        })
5127    });
5128
5129    let fs = FakeFs::new(cx.background_executor.clone());
5130    fs.insert_tree(
5131        "/project_root",
5132        json!({
5133            ".git": {},
5134            ".gitignore": "**/gitignored_dir",
5135            "dir_1": {
5136                "file_1.py": "# File 1_1 contents",
5137                "file_2.py": "# File 1_2 contents",
5138                "file_3.py": "# File 1_3 contents",
5139                "gitignored_dir": {
5140                    "file_a.py": "# File contents",
5141                    "file_b.py": "# File contents",
5142                    "file_c.py": "# File contents",
5143                },
5144            },
5145            "dir_2": {
5146                "file_1.py": "# File 2_1 contents",
5147                "file_2.py": "# File 2_2 contents",
5148                "file_3.py": "# File 2_3 contents",
5149            }
5150        }),
5151    )
5152    .await;
5153
5154    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5155    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5156    let workspace = window
5157        .read_with(cx, |mw, _| mw.workspace().clone())
5158        .unwrap();
5159    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5160    let panel = workspace.update_in(cx, ProjectPanel::new);
5161    cx.run_until_parked();
5162
5163    assert_eq!(
5164        visible_entries_as_strings(&panel, 0..20, cx),
5165        &[
5166            "v project_root",
5167            "    > .git",
5168            "    > dir_1",
5169            "    > dir_2",
5170            "      .gitignore",
5171        ]
5172    );
5173
5174    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5175        .expect("dir 1 file is not ignored and should have an entry");
5176    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5177        .expect("dir 2 file is not ignored and should have an entry");
5178    let gitignored_dir_file =
5179        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5180    assert_eq!(
5181        gitignored_dir_file, None,
5182        "File in the gitignored dir should not have an entry before its dir is toggled"
5183    );
5184
5185    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5186    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5187    cx.run_until_parked();
5188    assert_eq!(
5189        visible_entries_as_strings(&panel, 0..20, cx),
5190        &[
5191            "v project_root",
5192            "    > .git",
5193            "    v dir_1",
5194            "        v gitignored_dir  <== selected",
5195            "              file_a.py",
5196            "              file_b.py",
5197            "              file_c.py",
5198            "          file_1.py",
5199            "          file_2.py",
5200            "          file_3.py",
5201            "    > dir_2",
5202            "      .gitignore",
5203        ],
5204        "Should show gitignored dir file list in the project panel"
5205    );
5206    let gitignored_dir_file =
5207        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5208            .expect("after gitignored dir got opened, a file entry should be present");
5209
5210    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5211    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5212    assert_eq!(
5213        visible_entries_as_strings(&panel, 0..20, cx),
5214        &[
5215            "v project_root",
5216            "    > .git",
5217            "    > dir_1  <== selected",
5218            "    > dir_2",
5219            "      .gitignore",
5220        ],
5221        "Should hide all dir contents again and prepare for the explicit reveal test"
5222    );
5223
5224    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5225        panel.update(cx, |panel, cx| {
5226            panel.project.update(cx, |_, cx| {
5227                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5228            })
5229        });
5230        cx.run_until_parked();
5231        assert_eq!(
5232            visible_entries_as_strings(&panel, 0..20, cx),
5233            &[
5234                "v project_root",
5235                "    > .git",
5236                "    > dir_1  <== selected",
5237                "    > dir_2",
5238                "      .gitignore",
5239            ],
5240            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5241        );
5242    }
5243
5244    panel.update(cx, |panel, cx| {
5245        panel.project.update(cx, |_, cx| {
5246            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5247        })
5248    });
5249    cx.run_until_parked();
5250    assert_eq!(
5251        visible_entries_as_strings(&panel, 0..20, cx),
5252        &[
5253            "v project_root",
5254            "    > .git",
5255            "    v dir_1",
5256            "        > gitignored_dir",
5257            "          file_1.py  <== selected  <== marked",
5258            "          file_2.py",
5259            "          file_3.py",
5260            "    > dir_2",
5261            "      .gitignore",
5262        ],
5263        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5264    );
5265
5266    panel.update(cx, |panel, cx| {
5267        panel.project.update(cx, |_, cx| {
5268            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5269        })
5270    });
5271    cx.run_until_parked();
5272    assert_eq!(
5273        visible_entries_as_strings(&panel, 0..20, cx),
5274        &[
5275            "v project_root",
5276            "    > .git",
5277            "    v dir_1",
5278            "        > gitignored_dir",
5279            "          file_1.py",
5280            "          file_2.py",
5281            "          file_3.py",
5282            "    v dir_2",
5283            "          file_1.py  <== selected  <== marked",
5284            "          file_2.py",
5285            "          file_3.py",
5286            "      .gitignore",
5287        ],
5288        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5289    );
5290
5291    panel.update(cx, |panel, cx| {
5292        panel.project.update(cx, |_, cx| {
5293            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5294        })
5295    });
5296    cx.run_until_parked();
5297    assert_eq!(
5298        visible_entries_as_strings(&panel, 0..20, cx),
5299        &[
5300            "v project_root",
5301            "    > .git",
5302            "    v dir_1",
5303            "        v gitignored_dir",
5304            "              file_a.py  <== selected  <== marked",
5305            "              file_b.py",
5306            "              file_c.py",
5307            "          file_1.py",
5308            "          file_2.py",
5309            "          file_3.py",
5310            "    v dir_2",
5311            "          file_1.py",
5312            "          file_2.py",
5313            "          file_3.py",
5314            "      .gitignore",
5315        ],
5316        "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5317    );
5318}
5319
5320#[gpui::test]
5321async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5322    init_test(cx);
5323    cx.update(|cx| {
5324        cx.update_global::<SettingsStore, _>(|store, cx| {
5325            store.update_user_settings(cx, |settings| {
5326                settings.project.worktree.file_scan_exclusions =
5327                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5328            });
5329        });
5330    });
5331
5332    cx.update(|cx| {
5333        register_project_item::<TestProjectItemView>(cx);
5334    });
5335
5336    let fs = FakeFs::new(cx.executor());
5337    fs.insert_tree(
5338        "/root1",
5339        json!({
5340            ".dockerignore": "",
5341            ".git": {
5342                "HEAD": "",
5343            },
5344        }),
5345    )
5346    .await;
5347
5348    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5349    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5350    let workspace = window
5351        .read_with(cx, |mw, _| mw.workspace().clone())
5352        .unwrap();
5353    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5354    let panel = workspace.update_in(cx, |workspace, window, cx| {
5355        let panel = ProjectPanel::new(workspace, window, cx);
5356        workspace.add_panel(panel.clone(), window, cx);
5357        panel
5358    });
5359    cx.run_until_parked();
5360
5361    select_path(&panel, "root1", cx);
5362    assert_eq!(
5363        visible_entries_as_strings(&panel, 0..10, cx),
5364        &["v root1  <== selected", "      .dockerignore",]
5365    );
5366    workspace.update_in(cx, |workspace, _, cx| {
5367        assert!(
5368            workspace.active_item(cx).is_none(),
5369            "Should have no active items in the beginning"
5370        );
5371    });
5372
5373    let excluded_file_path = ".git/COMMIT_EDITMSG";
5374    let excluded_dir_path = "excluded_dir";
5375
5376    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5377    cx.run_until_parked();
5378    panel.update_in(cx, |panel, window, cx| {
5379        assert!(panel.filename_editor.read(cx).is_focused(window));
5380    });
5381    panel
5382        .update_in(cx, |panel, window, cx| {
5383            panel.filename_editor.update(cx, |editor, cx| {
5384                editor.set_text(excluded_file_path, window, cx)
5385            });
5386            panel.confirm_edit(true, window, cx).unwrap()
5387        })
5388        .await
5389        .unwrap();
5390
5391    assert_eq!(
5392        visible_entries_as_strings(&panel, 0..13, cx),
5393        &["v root1", "      .dockerignore"],
5394        "Excluded dir should not be shown after opening a file in it"
5395    );
5396    panel.update_in(cx, |panel, window, cx| {
5397        assert!(
5398            !panel.filename_editor.read(cx).is_focused(window),
5399            "Should have closed the file name editor"
5400        );
5401    });
5402    workspace.update_in(cx, |workspace, _, cx| {
5403        let active_entry_path = workspace
5404            .active_item(cx)
5405            .expect("should have opened and activated the excluded item")
5406            .act_as::<TestProjectItemView>(cx)
5407            .expect("should have opened the corresponding project item for the excluded item")
5408            .read(cx)
5409            .path
5410            .clone();
5411        assert_eq!(
5412            active_entry_path.path.as_ref(),
5413            rel_path(excluded_file_path),
5414            "Should open the excluded file"
5415        );
5416
5417        assert!(
5418            workspace.notification_ids().is_empty(),
5419            "Should have no notifications after opening an excluded file"
5420        );
5421    });
5422    assert!(
5423        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5424        "Should have created the excluded file"
5425    );
5426
5427    select_path(&panel, "root1", cx);
5428    panel.update_in(cx, |panel, window, cx| {
5429        panel.new_directory(&NewDirectory, window, cx)
5430    });
5431    cx.run_until_parked();
5432    panel.update_in(cx, |panel, window, cx| {
5433        assert!(panel.filename_editor.read(cx).is_focused(window));
5434    });
5435    panel
5436        .update_in(cx, |panel, window, cx| {
5437            panel.filename_editor.update(cx, |editor, cx| {
5438                editor.set_text(excluded_file_path, window, cx)
5439            });
5440            panel.confirm_edit(true, window, cx).unwrap()
5441        })
5442        .await
5443        .unwrap();
5444    cx.run_until_parked();
5445    assert_eq!(
5446        visible_entries_as_strings(&panel, 0..13, cx),
5447        &["v root1", "      .dockerignore"],
5448        "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5449    );
5450    panel.update_in(cx, |panel, window, cx| {
5451        assert!(
5452            !panel.filename_editor.read(cx).is_focused(window),
5453            "Should have closed the file name editor"
5454        );
5455    });
5456    workspace.update_in(cx, |workspace, _, cx| {
5457        let notifications = workspace.notification_ids();
5458        assert_eq!(
5459            notifications.len(),
5460            1,
5461            "Should receive one notification with the error message"
5462        );
5463        workspace.dismiss_notification(notifications.first().unwrap(), cx);
5464        assert!(workspace.notification_ids().is_empty());
5465    });
5466
5467    select_path(&panel, "root1", cx);
5468    panel.update_in(cx, |panel, window, cx| {
5469        panel.new_directory(&NewDirectory, window, cx)
5470    });
5471    cx.run_until_parked();
5472
5473    panel.update_in(cx, |panel, window, cx| {
5474        assert!(panel.filename_editor.read(cx).is_focused(window));
5475    });
5476
5477    panel
5478        .update_in(cx, |panel, window, cx| {
5479            panel.filename_editor.update(cx, |editor, cx| {
5480                editor.set_text(excluded_dir_path, window, cx)
5481            });
5482            panel.confirm_edit(true, window, cx).unwrap()
5483        })
5484        .await
5485        .unwrap();
5486
5487    cx.run_until_parked();
5488
5489    assert_eq!(
5490        visible_entries_as_strings(&panel, 0..13, cx),
5491        &["v root1", "      .dockerignore"],
5492        "Should not change the project panel after trying to create an excluded directory"
5493    );
5494    panel.update_in(cx, |panel, window, cx| {
5495        assert!(
5496            !panel.filename_editor.read(cx).is_focused(window),
5497            "Should have closed the file name editor"
5498        );
5499    });
5500    workspace.update_in(cx, |workspace, _, cx| {
5501        let notifications = workspace.notification_ids();
5502        assert_eq!(
5503            notifications.len(),
5504            1,
5505            "Should receive one notification explaining that no directory is actually shown"
5506        );
5507        workspace.dismiss_notification(notifications.first().unwrap(), cx);
5508        assert!(workspace.notification_ids().is_empty());
5509    });
5510    assert!(
5511        fs.is_dir(Path::new("/root1/excluded_dir")).await,
5512        "Should have created the excluded directory"
5513    );
5514}
5515
5516#[gpui::test]
5517async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5518    init_test_with_editor(cx);
5519
5520    let fs = FakeFs::new(cx.executor());
5521    fs.insert_tree(
5522        "/src",
5523        json!({
5524            "test": {
5525                "first.rs": "// First Rust file",
5526                "second.rs": "// Second Rust file",
5527                "third.rs": "// Third Rust file",
5528            }
5529        }),
5530    )
5531    .await;
5532
5533    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5534    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5535    let workspace = window
5536        .read_with(cx, |mw, _| mw.workspace().clone())
5537        .unwrap();
5538    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5539    let panel = workspace.update_in(cx, |workspace, window, cx| {
5540        let panel = ProjectPanel::new(workspace, window, cx);
5541        workspace.add_panel(panel.clone(), window, cx);
5542        panel
5543    });
5544    cx.run_until_parked();
5545
5546    select_path(&panel, "src", cx);
5547    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5548    cx.executor().run_until_parked();
5549    assert_eq!(
5550        visible_entries_as_strings(&panel, 0..10, cx),
5551        &[
5552            //
5553            "v src  <== selected",
5554            "    > test"
5555        ]
5556    );
5557    panel.update_in(cx, |panel, window, cx| {
5558        panel.new_directory(&NewDirectory, window, cx)
5559    });
5560    cx.executor().run_until_parked();
5561    panel.update_in(cx, |panel, window, cx| {
5562        assert!(panel.filename_editor.read(cx).is_focused(window));
5563    });
5564    assert_eq!(
5565        visible_entries_as_strings(&panel, 0..10, cx),
5566        &[
5567            //
5568            "v src",
5569            "    > [EDITOR: '']  <== selected",
5570            "    > test"
5571        ]
5572    );
5573
5574    panel.update_in(cx, |panel, window, cx| {
5575        panel.cancel(&menu::Cancel, window, cx);
5576    });
5577    cx.executor().run_until_parked();
5578    assert_eq!(
5579        visible_entries_as_strings(&panel, 0..10, cx),
5580        &[
5581            //
5582            "v src  <== selected",
5583            "    > test"
5584        ]
5585    );
5586
5587    panel.update_in(cx, |panel, window, cx| {
5588        panel.new_directory(&NewDirectory, window, cx)
5589    });
5590    cx.executor().run_until_parked();
5591    panel.update_in(cx, |panel, window, cx| {
5592        assert!(panel.filename_editor.read(cx).is_focused(window));
5593    });
5594    assert_eq!(
5595        visible_entries_as_strings(&panel, 0..10, cx),
5596        &[
5597            //
5598            "v src",
5599            "    > [EDITOR: '']  <== selected",
5600            "    > test"
5601        ]
5602    );
5603    workspace.update_in(cx, |_, window, _| window.blur());
5604    cx.executor().run_until_parked();
5605    assert_eq!(
5606        visible_entries_as_strings(&panel, 0..10, cx),
5607        &[
5608            //
5609            "v src  <== selected",
5610            "    > test"
5611        ]
5612    );
5613}
5614
5615#[gpui::test]
5616async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5617    init_test_with_editor(cx);
5618
5619    let fs = FakeFs::new(cx.executor());
5620    fs.insert_tree(
5621        "/root",
5622        json!({
5623            "dir1": {
5624                "subdir1": {},
5625                "file1.txt": "",
5626                "file2.txt": "",
5627            },
5628            "dir2": {
5629                "subdir2": {},
5630                "file3.txt": "",
5631                "file4.txt": "",
5632            },
5633            "file5.txt": "",
5634            "file6.txt": "",
5635        }),
5636    )
5637    .await;
5638
5639    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5640    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5641    let workspace = window
5642        .read_with(cx, |mw, _| mw.workspace().clone())
5643        .unwrap();
5644    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5645    let panel = workspace.update_in(cx, ProjectPanel::new);
5646    cx.run_until_parked();
5647
5648    toggle_expand_dir(&panel, "root/dir1", cx);
5649    toggle_expand_dir(&panel, "root/dir2", cx);
5650
5651    // Test Case 1: Delete middle file in directory
5652    select_path(&panel, "root/dir1/file1.txt", cx);
5653    assert_eq!(
5654        visible_entries_as_strings(&panel, 0..15, cx),
5655        &[
5656            "v root",
5657            "    v dir1",
5658            "        > subdir1",
5659            "          file1.txt  <== selected",
5660            "          file2.txt",
5661            "    v dir2",
5662            "        > subdir2",
5663            "          file3.txt",
5664            "          file4.txt",
5665            "      file5.txt",
5666            "      file6.txt",
5667        ],
5668        "Initial state before deleting middle file"
5669    );
5670
5671    submit_deletion(&panel, cx);
5672    assert_eq!(
5673        visible_entries_as_strings(&panel, 0..15, cx),
5674        &[
5675            "v root",
5676            "    v dir1",
5677            "        > subdir1",
5678            "          file2.txt  <== selected",
5679            "    v dir2",
5680            "        > subdir2",
5681            "          file3.txt",
5682            "          file4.txt",
5683            "      file5.txt",
5684            "      file6.txt",
5685        ],
5686        "Should select next file after deleting middle file"
5687    );
5688
5689    // Test Case 2: Delete last file in directory
5690    submit_deletion(&panel, cx);
5691    assert_eq!(
5692        visible_entries_as_strings(&panel, 0..15, cx),
5693        &[
5694            "v root",
5695            "    v dir1",
5696            "        > subdir1  <== selected",
5697            "    v dir2",
5698            "        > subdir2",
5699            "          file3.txt",
5700            "          file4.txt",
5701            "      file5.txt",
5702            "      file6.txt",
5703        ],
5704        "Should select next directory when last file is deleted"
5705    );
5706
5707    // Test Case 3: Delete root level file
5708    select_path(&panel, "root/file6.txt", cx);
5709    assert_eq!(
5710        visible_entries_as_strings(&panel, 0..15, cx),
5711        &[
5712            "v root",
5713            "    v dir1",
5714            "        > subdir1",
5715            "    v dir2",
5716            "        > subdir2",
5717            "          file3.txt",
5718            "          file4.txt",
5719            "      file5.txt",
5720            "      file6.txt  <== selected",
5721        ],
5722        "Initial state before deleting root level file"
5723    );
5724
5725    submit_deletion(&panel, cx);
5726    assert_eq!(
5727        visible_entries_as_strings(&panel, 0..15, cx),
5728        &[
5729            "v root",
5730            "    v dir1",
5731            "        > subdir1",
5732            "    v dir2",
5733            "        > subdir2",
5734            "          file3.txt",
5735            "          file4.txt",
5736            "      file5.txt  <== selected",
5737        ],
5738        "Should select prev entry at root level"
5739    );
5740}
5741
5742#[gpui::test]
5743async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
5744    init_test_with_editor(cx);
5745
5746    let fs = FakeFs::new(cx.executor());
5747    fs.insert_tree(
5748        path!("/root"),
5749        json!({
5750            "aa": "// Testing 1",
5751            "bb": "// Testing 2",
5752            "cc": "// Testing 3",
5753            "dd": "// Testing 4",
5754            "ee": "// Testing 5",
5755            "ff": "// Testing 6",
5756            "gg": "// Testing 7",
5757            "hh": "// Testing 8",
5758            "ii": "// Testing 8",
5759            ".gitignore": "bb\ndd\nee\nff\nii\n'",
5760        }),
5761    )
5762    .await;
5763
5764    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5765    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5766    let workspace = window
5767        .read_with(cx, |mw, _| mw.workspace().clone())
5768        .unwrap();
5769    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5770
5771    // Test 1: Auto selection with one gitignored file next to the deleted file
5772    cx.update(|_, cx| {
5773        let settings = *ProjectPanelSettings::get_global(cx);
5774        ProjectPanelSettings::override_global(
5775            ProjectPanelSettings {
5776                hide_gitignore: true,
5777                ..settings
5778            },
5779            cx,
5780        );
5781    });
5782
5783    let panel = workspace.update_in(cx, ProjectPanel::new);
5784    cx.run_until_parked();
5785
5786    select_path(&panel, "root/aa", cx);
5787    assert_eq!(
5788        visible_entries_as_strings(&panel, 0..10, cx),
5789        &[
5790            "v root",
5791            "      .gitignore",
5792            "      aa  <== selected",
5793            "      cc",
5794            "      gg",
5795            "      hh"
5796        ],
5797        "Initial state should hide files on .gitignore"
5798    );
5799
5800    submit_deletion(&panel, cx);
5801
5802    assert_eq!(
5803        visible_entries_as_strings(&panel, 0..10, cx),
5804        &[
5805            "v root",
5806            "      .gitignore",
5807            "      cc  <== selected",
5808            "      gg",
5809            "      hh"
5810        ],
5811        "Should select next entry not on .gitignore"
5812    );
5813
5814    // Test 2: Auto selection with many gitignored files next to the deleted file
5815    submit_deletion(&panel, cx);
5816    assert_eq!(
5817        visible_entries_as_strings(&panel, 0..10, cx),
5818        &[
5819            "v root",
5820            "      .gitignore",
5821            "      gg  <== selected",
5822            "      hh"
5823        ],
5824        "Should select next entry not on .gitignore"
5825    );
5826
5827    // Test 3: Auto selection of entry before deleted file
5828    select_path(&panel, "root/hh", cx);
5829    assert_eq!(
5830        visible_entries_as_strings(&panel, 0..10, cx),
5831        &[
5832            "v root",
5833            "      .gitignore",
5834            "      gg",
5835            "      hh  <== selected"
5836        ],
5837        "Should select next entry not on .gitignore"
5838    );
5839    submit_deletion(&panel, cx);
5840    assert_eq!(
5841        visible_entries_as_strings(&panel, 0..10, cx),
5842        &["v root", "      .gitignore", "      gg  <== selected"],
5843        "Should select next entry not on .gitignore"
5844    );
5845}
5846
5847#[gpui::test]
5848async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
5849    init_test_with_editor(cx);
5850
5851    let fs = FakeFs::new(cx.executor());
5852    fs.insert_tree(
5853        path!("/root"),
5854        json!({
5855            "dir1": {
5856                "file1": "// Testing",
5857                "file2": "// Testing",
5858                "file3": "// Testing"
5859            },
5860            "aa": "// Testing",
5861            ".gitignore": "file1\nfile3\n",
5862        }),
5863    )
5864    .await;
5865
5866    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5867    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5868    let workspace = window
5869        .read_with(cx, |mw, _| mw.workspace().clone())
5870        .unwrap();
5871    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5872
5873    cx.update(|_, cx| {
5874        let settings = *ProjectPanelSettings::get_global(cx);
5875        ProjectPanelSettings::override_global(
5876            ProjectPanelSettings {
5877                hide_gitignore: true,
5878                ..settings
5879            },
5880            cx,
5881        );
5882    });
5883
5884    let panel = workspace.update_in(cx, ProjectPanel::new);
5885    cx.run_until_parked();
5886
5887    // Test 1: Visible items should exclude files on gitignore
5888    toggle_expand_dir(&panel, "root/dir1", cx);
5889    select_path(&panel, "root/dir1/file2", cx);
5890    assert_eq!(
5891        visible_entries_as_strings(&panel, 0..10, cx),
5892        &[
5893            "v root",
5894            "    v dir1",
5895            "          file2  <== selected",
5896            "      .gitignore",
5897            "      aa"
5898        ],
5899        "Initial state should hide files on .gitignore"
5900    );
5901    submit_deletion(&panel, cx);
5902
5903    // Test 2: Auto selection should go to the parent
5904    assert_eq!(
5905        visible_entries_as_strings(&panel, 0..10, cx),
5906        &[
5907            "v root",
5908            "    v dir1  <== selected",
5909            "      .gitignore",
5910            "      aa"
5911        ],
5912        "Initial state should hide files on .gitignore"
5913    );
5914}
5915
5916#[gpui::test]
5917async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
5918    init_test_with_editor(cx);
5919
5920    let fs = FakeFs::new(cx.executor());
5921    fs.insert_tree(
5922        "/root",
5923        json!({
5924            "dir1": {
5925                "subdir1": {
5926                    "a.txt": "",
5927                    "b.txt": ""
5928                },
5929                "file1.txt": "",
5930            },
5931            "dir2": {
5932                "subdir2": {
5933                    "c.txt": "",
5934                    "d.txt": ""
5935                },
5936                "file2.txt": "",
5937            },
5938            "file3.txt": "",
5939        }),
5940    )
5941    .await;
5942
5943    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5944    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5945    let workspace = window
5946        .read_with(cx, |mw, _| mw.workspace().clone())
5947        .unwrap();
5948    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5949    let panel = workspace.update_in(cx, ProjectPanel::new);
5950    cx.run_until_parked();
5951
5952    toggle_expand_dir(&panel, "root/dir1", cx);
5953    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5954    toggle_expand_dir(&panel, "root/dir2", cx);
5955    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5956
5957    // Test Case 1: Select and delete nested directory with parent
5958    cx.simulate_modifiers_change(gpui::Modifiers {
5959        control: true,
5960        ..Default::default()
5961    });
5962    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5963    select_path_with_mark(&panel, "root/dir1", cx);
5964
5965    assert_eq!(
5966        visible_entries_as_strings(&panel, 0..15, cx),
5967        &[
5968            "v root",
5969            "    v dir1  <== selected  <== marked",
5970            "        v subdir1  <== marked",
5971            "              a.txt",
5972            "              b.txt",
5973            "          file1.txt",
5974            "    v dir2",
5975            "        v subdir2",
5976            "              c.txt",
5977            "              d.txt",
5978            "          file2.txt",
5979            "      file3.txt",
5980        ],
5981        "Initial state before deleting nested directory with parent"
5982    );
5983
5984    submit_deletion(&panel, cx);
5985    assert_eq!(
5986        visible_entries_as_strings(&panel, 0..15, cx),
5987        &[
5988            "v root",
5989            "    v dir2  <== selected",
5990            "        v subdir2",
5991            "              c.txt",
5992            "              d.txt",
5993            "          file2.txt",
5994            "      file3.txt",
5995        ],
5996        "Should select next directory after deleting directory with parent"
5997    );
5998
5999    // Test Case 2: Select mixed files and directories across levels
6000    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
6001    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
6002    select_path_with_mark(&panel, "root/file3.txt", cx);
6003
6004    assert_eq!(
6005        visible_entries_as_strings(&panel, 0..15, cx),
6006        &[
6007            "v root",
6008            "    v dir2",
6009            "        v subdir2",
6010            "              c.txt  <== marked",
6011            "              d.txt",
6012            "          file2.txt  <== marked",
6013            "      file3.txt  <== selected  <== marked",
6014        ],
6015        "Initial state before deleting"
6016    );
6017
6018    submit_deletion(&panel, cx);
6019    assert_eq!(
6020        visible_entries_as_strings(&panel, 0..15, cx),
6021        &[
6022            "v root",
6023            "    v dir2  <== selected",
6024            "        v subdir2",
6025            "              d.txt",
6026        ],
6027        "Should select sibling directory"
6028    );
6029}
6030
6031#[gpui::test]
6032async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
6033    init_test_with_editor(cx);
6034
6035    let fs = FakeFs::new(cx.executor());
6036    fs.insert_tree(
6037        "/root",
6038        json!({
6039            "dir1": {
6040                "subdir1": {
6041                    "a.txt": "",
6042                    "b.txt": ""
6043                },
6044                "file1.txt": "",
6045            },
6046            "dir2": {
6047                "subdir2": {
6048                    "c.txt": "",
6049                    "d.txt": ""
6050                },
6051                "file2.txt": "",
6052            },
6053            "file3.txt": "",
6054            "file4.txt": "",
6055        }),
6056    )
6057    .await;
6058
6059    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6060    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6061    let workspace = window
6062        .read_with(cx, |mw, _| mw.workspace().clone())
6063        .unwrap();
6064    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6065    let panel = workspace.update_in(cx, ProjectPanel::new);
6066    cx.run_until_parked();
6067
6068    toggle_expand_dir(&panel, "root/dir1", cx);
6069    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6070    toggle_expand_dir(&panel, "root/dir2", cx);
6071    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6072
6073    // Test Case 1: Select all root files and directories
6074    cx.simulate_modifiers_change(gpui::Modifiers {
6075        control: true,
6076        ..Default::default()
6077    });
6078    select_path_with_mark(&panel, "root/dir1", cx);
6079    select_path_with_mark(&panel, "root/dir2", cx);
6080    select_path_with_mark(&panel, "root/file3.txt", cx);
6081    select_path_with_mark(&panel, "root/file4.txt", cx);
6082    assert_eq!(
6083        visible_entries_as_strings(&panel, 0..20, cx),
6084        &[
6085            "v root",
6086            "    v dir1  <== marked",
6087            "        v subdir1",
6088            "              a.txt",
6089            "              b.txt",
6090            "          file1.txt",
6091            "    v dir2  <== marked",
6092            "        v subdir2",
6093            "              c.txt",
6094            "              d.txt",
6095            "          file2.txt",
6096            "      file3.txt  <== marked",
6097            "      file4.txt  <== selected  <== marked",
6098        ],
6099        "State before deleting all contents"
6100    );
6101
6102    submit_deletion(&panel, cx);
6103    assert_eq!(
6104        visible_entries_as_strings(&panel, 0..20, cx),
6105        &["v root  <== selected"],
6106        "Only empty root directory should remain after deleting all contents"
6107    );
6108}
6109
6110#[gpui::test]
6111async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
6112    init_test_with_editor(cx);
6113
6114    let fs = FakeFs::new(cx.executor());
6115    fs.insert_tree(
6116        "/root",
6117        json!({
6118            "dir1": {
6119                "subdir1": {
6120                    "file_a.txt": "content a",
6121                    "file_b.txt": "content b",
6122                },
6123                "subdir2": {
6124                    "file_c.txt": "content c",
6125                },
6126                "file1.txt": "content 1",
6127            },
6128            "dir2": {
6129                "file2.txt": "content 2",
6130            },
6131        }),
6132    )
6133    .await;
6134
6135    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6136    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6137    let workspace = window
6138        .read_with(cx, |mw, _| mw.workspace().clone())
6139        .unwrap();
6140    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6141    let panel = workspace.update_in(cx, ProjectPanel::new);
6142    cx.run_until_parked();
6143
6144    toggle_expand_dir(&panel, "root/dir1", cx);
6145    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6146    toggle_expand_dir(&panel, "root/dir2", cx);
6147    cx.simulate_modifiers_change(gpui::Modifiers {
6148        control: true,
6149        ..Default::default()
6150    });
6151
6152    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6153    select_path_with_mark(&panel, "root/dir1", cx);
6154    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6155    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6156
6157    assert_eq!(
6158        visible_entries_as_strings(&panel, 0..20, cx),
6159        &[
6160            "v root",
6161            "    v dir1  <== marked",
6162            "        v subdir1  <== marked",
6163            "              file_a.txt  <== selected  <== marked",
6164            "              file_b.txt",
6165            "        > subdir2",
6166            "          file1.txt",
6167            "    v dir2",
6168            "          file2.txt",
6169        ],
6170        "State with parent dir, subdir, and file selected"
6171    );
6172    submit_deletion(&panel, cx);
6173    assert_eq!(
6174        visible_entries_as_strings(&panel, 0..20, cx),
6175        &["v root", "    v dir2  <== selected", "          file2.txt",],
6176        "Only dir2 should remain after deletion"
6177    );
6178}
6179
6180#[gpui::test]
6181async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
6182    init_test_with_editor(cx);
6183
6184    let fs = FakeFs::new(cx.executor());
6185    // First worktree
6186    fs.insert_tree(
6187        "/root1",
6188        json!({
6189            "dir1": {
6190                "file1.txt": "content 1",
6191                "file2.txt": "content 2",
6192            },
6193            "dir2": {
6194                "file3.txt": "content 3",
6195            },
6196        }),
6197    )
6198    .await;
6199
6200    // Second worktree
6201    fs.insert_tree(
6202        "/root2",
6203        json!({
6204            "dir3": {
6205                "file4.txt": "content 4",
6206                "file5.txt": "content 5",
6207            },
6208            "file6.txt": "content 6",
6209        }),
6210    )
6211    .await;
6212
6213    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6214    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6215    let workspace = window
6216        .read_with(cx, |mw, _| mw.workspace().clone())
6217        .unwrap();
6218    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6219    let panel = workspace.update_in(cx, ProjectPanel::new);
6220    cx.run_until_parked();
6221
6222    // Expand all directories for testing
6223    toggle_expand_dir(&panel, "root1/dir1", cx);
6224    toggle_expand_dir(&panel, "root1/dir2", cx);
6225    toggle_expand_dir(&panel, "root2/dir3", cx);
6226
6227    // Test Case 1: Delete files across different worktrees
6228    cx.simulate_modifiers_change(gpui::Modifiers {
6229        control: true,
6230        ..Default::default()
6231    });
6232    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
6233    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
6234
6235    assert_eq!(
6236        visible_entries_as_strings(&panel, 0..20, cx),
6237        &[
6238            "v root1",
6239            "    v dir1",
6240            "          file1.txt  <== marked",
6241            "          file2.txt",
6242            "    v dir2",
6243            "          file3.txt",
6244            "v root2",
6245            "    v dir3",
6246            "          file4.txt  <== selected  <== marked",
6247            "          file5.txt",
6248            "      file6.txt",
6249        ],
6250        "Initial state with files selected from different worktrees"
6251    );
6252
6253    submit_deletion(&panel, cx);
6254    assert_eq!(
6255        visible_entries_as_strings(&panel, 0..20, cx),
6256        &[
6257            "v root1",
6258            "    v dir1",
6259            "          file2.txt",
6260            "    v dir2",
6261            "          file3.txt",
6262            "v root2",
6263            "    v dir3",
6264            "          file5.txt  <== selected",
6265            "      file6.txt",
6266        ],
6267        "Should select next file in the last worktree after deletion"
6268    );
6269
6270    // Test Case 2: Delete directories from different worktrees
6271    select_path_with_mark(&panel, "root1/dir1", cx);
6272    select_path_with_mark(&panel, "root2/dir3", cx);
6273
6274    assert_eq!(
6275        visible_entries_as_strings(&panel, 0..20, cx),
6276        &[
6277            "v root1",
6278            "    v dir1  <== marked",
6279            "          file2.txt",
6280            "    v dir2",
6281            "          file3.txt",
6282            "v root2",
6283            "    v dir3  <== selected  <== marked",
6284            "          file5.txt",
6285            "      file6.txt",
6286        ],
6287        "State with directories marked from different worktrees"
6288    );
6289
6290    submit_deletion(&panel, cx);
6291    assert_eq!(
6292        visible_entries_as_strings(&panel, 0..20, cx),
6293        &[
6294            "v root1",
6295            "    v dir2",
6296            "          file3.txt",
6297            "v root2",
6298            "      file6.txt  <== selected",
6299        ],
6300        "Should select remaining file in last worktree after directory deletion"
6301    );
6302
6303    // Test Case 4: Delete all remaining files except roots
6304    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
6305    select_path_with_mark(&panel, "root2/file6.txt", cx);
6306
6307    assert_eq!(
6308        visible_entries_as_strings(&panel, 0..20, cx),
6309        &[
6310            "v root1",
6311            "    v dir2",
6312            "          file3.txt  <== marked",
6313            "v root2",
6314            "      file6.txt  <== selected  <== marked",
6315        ],
6316        "State with all remaining files marked"
6317    );
6318
6319    submit_deletion(&panel, cx);
6320    assert_eq!(
6321        visible_entries_as_strings(&panel, 0..20, cx),
6322        &["v root1", "    v dir2", "v root2  <== selected"],
6323        "Second parent root should be selected after deleting"
6324    );
6325}
6326
6327#[gpui::test]
6328async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
6329    init_test_with_editor(cx);
6330
6331    let fs = FakeFs::new(cx.executor());
6332    fs.insert_tree(
6333        "/root",
6334        json!({
6335            "dir1": {
6336                "file1.txt": "",
6337                "file2.txt": "",
6338                "file3.txt": "",
6339            },
6340            "dir2": {
6341                "file4.txt": "",
6342                "file5.txt": "",
6343            },
6344        }),
6345    )
6346    .await;
6347
6348    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6349    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6350    let workspace = window
6351        .read_with(cx, |mw, _| mw.workspace().clone())
6352        .unwrap();
6353    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6354    let panel = workspace.update_in(cx, ProjectPanel::new);
6355    cx.run_until_parked();
6356
6357    toggle_expand_dir(&panel, "root/dir1", cx);
6358    toggle_expand_dir(&panel, "root/dir2", cx);
6359
6360    cx.simulate_modifiers_change(gpui::Modifiers {
6361        control: true,
6362        ..Default::default()
6363    });
6364
6365    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6366    select_path(&panel, "root/dir1/file1.txt", cx);
6367
6368    assert_eq!(
6369        visible_entries_as_strings(&panel, 0..15, cx),
6370        &[
6371            "v root",
6372            "    v dir1",
6373            "          file1.txt  <== selected",
6374            "          file2.txt  <== marked",
6375            "          file3.txt",
6376            "    v dir2",
6377            "          file4.txt",
6378            "          file5.txt",
6379        ],
6380        "Initial state with one marked entry and different selection"
6381    );
6382
6383    // Delete should operate on the selected entry (file1.txt)
6384    submit_deletion(&panel, cx);
6385    assert_eq!(
6386        visible_entries_as_strings(&panel, 0..15, cx),
6387        &[
6388            "v root",
6389            "    v dir1",
6390            "          file2.txt  <== selected  <== marked",
6391            "          file3.txt",
6392            "    v dir2",
6393            "          file4.txt",
6394            "          file5.txt",
6395        ],
6396        "Should delete selected file, not marked file"
6397    );
6398
6399    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6400    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6401    select_path(&panel, "root/dir2/file5.txt", cx);
6402
6403    assert_eq!(
6404        visible_entries_as_strings(&panel, 0..15, cx),
6405        &[
6406            "v root",
6407            "    v dir1",
6408            "          file2.txt  <== marked",
6409            "          file3.txt  <== marked",
6410            "    v dir2",
6411            "          file4.txt  <== marked",
6412            "          file5.txt  <== selected",
6413        ],
6414        "Initial state with multiple marked entries and different selection"
6415    );
6416
6417    // Delete should operate on all marked entries, ignoring the selection
6418    submit_deletion(&panel, cx);
6419    assert_eq!(
6420        visible_entries_as_strings(&panel, 0..15, cx),
6421        &[
6422            "v root",
6423            "    v dir1",
6424            "    v dir2",
6425            "          file5.txt  <== selected",
6426        ],
6427        "Should delete all marked files, leaving only the selected file"
6428    );
6429}
6430
6431#[gpui::test]
6432async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6433    init_test_with_editor(cx);
6434
6435    let fs = FakeFs::new(cx.executor());
6436    fs.insert_tree(
6437        "/root_b",
6438        json!({
6439            "dir1": {
6440                "file1.txt": "content 1",
6441                "file2.txt": "content 2",
6442            },
6443        }),
6444    )
6445    .await;
6446
6447    fs.insert_tree(
6448        "/root_c",
6449        json!({
6450            "dir2": {},
6451        }),
6452    )
6453    .await;
6454
6455    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6456    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6457    let workspace = window
6458        .read_with(cx, |mw, _| mw.workspace().clone())
6459        .unwrap();
6460    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6461    let panel = workspace.update_in(cx, ProjectPanel::new);
6462    cx.run_until_parked();
6463
6464    toggle_expand_dir(&panel, "root_b/dir1", cx);
6465    toggle_expand_dir(&panel, "root_c/dir2", cx);
6466
6467    cx.simulate_modifiers_change(gpui::Modifiers {
6468        control: true,
6469        ..Default::default()
6470    });
6471    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6472    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6473
6474    assert_eq!(
6475        visible_entries_as_strings(&panel, 0..20, cx),
6476        &[
6477            "v root_b",
6478            "    v dir1",
6479            "          file1.txt  <== marked",
6480            "          file2.txt  <== selected  <== marked",
6481            "v root_c",
6482            "    v dir2",
6483        ],
6484        "Initial state with files marked in root_b"
6485    );
6486
6487    submit_deletion(&panel, cx);
6488    assert_eq!(
6489        visible_entries_as_strings(&panel, 0..20, cx),
6490        &[
6491            "v root_b",
6492            "    v dir1  <== selected",
6493            "v root_c",
6494            "    v dir2",
6495        ],
6496        "After deletion in root_b as it's last deletion, selection should be in root_b"
6497    );
6498
6499    select_path_with_mark(&panel, "root_c/dir2", cx);
6500
6501    submit_deletion(&panel, cx);
6502    assert_eq!(
6503        visible_entries_as_strings(&panel, 0..20, cx),
6504        &["v root_b", "    v dir1", "v root_c  <== selected",],
6505        "After deleting from root_c, it should remain in root_c"
6506    );
6507}
6508
6509fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6510    let path = rel_path(path);
6511    panel.update_in(cx, |panel, window, cx| {
6512        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6513            let worktree = worktree.read(cx);
6514            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6515                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6516                panel.toggle_expanded(entry_id, window, cx);
6517                return;
6518            }
6519        }
6520        panic!("no worktree for path {:?}", path);
6521    });
6522    cx.run_until_parked();
6523}
6524
6525#[gpui::test]
6526async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6527    init_test_with_editor(cx);
6528
6529    let fs = FakeFs::new(cx.executor());
6530    fs.insert_tree(
6531        path!("/root"),
6532        json!({
6533            ".gitignore": "**/ignored_dir\n**/ignored_nested",
6534            "dir1": {
6535                "empty1": {
6536                    "empty2": {
6537                        "empty3": {
6538                            "file.txt": ""
6539                        }
6540                    }
6541                },
6542                "subdir1": {
6543                    "file1.txt": "",
6544                    "file2.txt": "",
6545                    "ignored_nested": {
6546                        "ignored_file.txt": ""
6547                    }
6548                },
6549                "ignored_dir": {
6550                    "subdir": {
6551                        "deep_file.txt": ""
6552                    }
6553                }
6554            }
6555        }),
6556    )
6557    .await;
6558
6559    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6560    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6561    let workspace = window
6562        .read_with(cx, |mw, _| mw.workspace().clone())
6563        .unwrap();
6564    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6565
6566    // Test 1: When auto-fold is enabled
6567    cx.update(|_, cx| {
6568        let settings = *ProjectPanelSettings::get_global(cx);
6569        ProjectPanelSettings::override_global(
6570            ProjectPanelSettings {
6571                auto_fold_dirs: true,
6572                ..settings
6573            },
6574            cx,
6575        );
6576    });
6577
6578    let panel = workspace.update_in(cx, ProjectPanel::new);
6579    cx.run_until_parked();
6580
6581    assert_eq!(
6582        visible_entries_as_strings(&panel, 0..20, cx),
6583        &["v root", "    > dir1", "      .gitignore",],
6584        "Initial state should show collapsed root structure"
6585    );
6586
6587    toggle_expand_dir(&panel, "root/dir1", cx);
6588    assert_eq!(
6589        visible_entries_as_strings(&panel, 0..20, cx),
6590        &[
6591            "v root",
6592            "    v dir1  <== selected",
6593            "        > empty1/empty2/empty3",
6594            "        > ignored_dir",
6595            "        > subdir1",
6596            "      .gitignore",
6597        ],
6598        "Should show first level with auto-folded dirs and ignored dir visible"
6599    );
6600
6601    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6602    panel.update_in(cx, |panel, window, cx| {
6603        let project = panel.project.read(cx);
6604        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6605        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6606        panel.update_visible_entries(None, false, false, window, cx);
6607    });
6608    cx.run_until_parked();
6609
6610    assert_eq!(
6611        visible_entries_as_strings(&panel, 0..20, cx),
6612        &[
6613            "v root",
6614            "    v dir1  <== selected",
6615            "        v empty1",
6616            "            v empty2",
6617            "                v empty3",
6618            "                      file.txt",
6619            "        > ignored_dir",
6620            "        v subdir1",
6621            "            > ignored_nested",
6622            "              file1.txt",
6623            "              file2.txt",
6624            "      .gitignore",
6625        ],
6626        "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6627    );
6628
6629    // Test 2: When auto-fold is disabled
6630    cx.update(|_, cx| {
6631        let settings = *ProjectPanelSettings::get_global(cx);
6632        ProjectPanelSettings::override_global(
6633            ProjectPanelSettings {
6634                auto_fold_dirs: false,
6635                ..settings
6636            },
6637            cx,
6638        );
6639    });
6640
6641    panel.update_in(cx, |panel, window, cx| {
6642        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6643    });
6644
6645    toggle_expand_dir(&panel, "root/dir1", cx);
6646    assert_eq!(
6647        visible_entries_as_strings(&panel, 0..20, cx),
6648        &[
6649            "v root",
6650            "    v dir1  <== selected",
6651            "        > empty1",
6652            "        > ignored_dir",
6653            "        > subdir1",
6654            "      .gitignore",
6655        ],
6656        "With auto-fold disabled: should show all directories separately"
6657    );
6658
6659    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6660    panel.update_in(cx, |panel, window, cx| {
6661        let project = panel.project.read(cx);
6662        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6663        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6664        panel.update_visible_entries(None, false, false, window, cx);
6665    });
6666    cx.run_until_parked();
6667
6668    assert_eq!(
6669        visible_entries_as_strings(&panel, 0..20, cx),
6670        &[
6671            "v root",
6672            "    v dir1  <== selected",
6673            "        v empty1",
6674            "            v empty2",
6675            "                v empty3",
6676            "                      file.txt",
6677            "        > ignored_dir",
6678            "        v subdir1",
6679            "            > ignored_nested",
6680            "              file1.txt",
6681            "              file2.txt",
6682            "      .gitignore",
6683        ],
6684        "After expand_all without auto-fold: should expand all dirs normally, \
6685         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6686    );
6687
6688    // Test 3: When explicitly called on ignored directory
6689    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6690    panel.update_in(cx, |panel, window, cx| {
6691        let project = panel.project.read(cx);
6692        let worktree = project.worktrees(cx).next().unwrap().read(cx);
6693        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6694        panel.update_visible_entries(None, false, false, window, cx);
6695    });
6696    cx.run_until_parked();
6697
6698    assert_eq!(
6699        visible_entries_as_strings(&panel, 0..20, cx),
6700        &[
6701            "v root",
6702            "    v dir1  <== selected",
6703            "        v empty1",
6704            "            v empty2",
6705            "                v empty3",
6706            "                      file.txt",
6707            "        v ignored_dir",
6708            "            v subdir",
6709            "                  deep_file.txt",
6710            "        v subdir1",
6711            "            > ignored_nested",
6712            "              file1.txt",
6713            "              file2.txt",
6714            "      .gitignore",
6715        ],
6716        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
6717    );
6718}
6719
6720#[gpui::test]
6721async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
6722    init_test(cx);
6723
6724    let fs = FakeFs::new(cx.executor());
6725    fs.insert_tree(
6726        path!("/root"),
6727        json!({
6728            "dir1": {
6729                "subdir1": {
6730                    "nested1": {
6731                        "file1.txt": "",
6732                        "file2.txt": ""
6733                    },
6734                },
6735                "subdir2": {
6736                    "file4.txt": ""
6737                }
6738            },
6739            "dir2": {
6740                "single_file": {
6741                    "file5.txt": ""
6742                }
6743            }
6744        }),
6745    )
6746    .await;
6747
6748    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6749    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6750    let workspace = window
6751        .read_with(cx, |mw, _| mw.workspace().clone())
6752        .unwrap();
6753    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6754
6755    // Test 1: Basic collapsing
6756    {
6757        let panel = workspace.update_in(cx, ProjectPanel::new);
6758        cx.run_until_parked();
6759
6760        toggle_expand_dir(&panel, "root/dir1", cx);
6761        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6762        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6763        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6764
6765        assert_eq!(
6766            visible_entries_as_strings(&panel, 0..20, cx),
6767            &[
6768                "v root",
6769                "    v dir1",
6770                "        v subdir1",
6771                "            v nested1",
6772                "                  file1.txt",
6773                "                  file2.txt",
6774                "        v subdir2  <== selected",
6775                "              file4.txt",
6776                "    > dir2",
6777            ],
6778            "Initial state with everything expanded"
6779        );
6780
6781        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6782        panel.update_in(cx, |panel, window, cx| {
6783            let project = panel.project.read(cx);
6784            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6785            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6786            panel.update_visible_entries(None, false, false, window, cx);
6787        });
6788        cx.run_until_parked();
6789
6790        assert_eq!(
6791            visible_entries_as_strings(&panel, 0..20, cx),
6792            &["v root", "    > dir1", "    > dir2",],
6793            "All subdirs under dir1 should be collapsed"
6794        );
6795    }
6796
6797    // Test 2: With auto-fold enabled
6798    {
6799        cx.update(|_, cx| {
6800            let settings = *ProjectPanelSettings::get_global(cx);
6801            ProjectPanelSettings::override_global(
6802                ProjectPanelSettings {
6803                    auto_fold_dirs: true,
6804                    ..settings
6805                },
6806                cx,
6807            );
6808        });
6809
6810        let panel = workspace.update_in(cx, ProjectPanel::new);
6811        cx.run_until_parked();
6812
6813        toggle_expand_dir(&panel, "root/dir1", cx);
6814        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6815        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6816
6817        assert_eq!(
6818            visible_entries_as_strings(&panel, 0..20, cx),
6819            &[
6820                "v root",
6821                "    v dir1",
6822                "        v subdir1/nested1  <== selected",
6823                "              file1.txt",
6824                "              file2.txt",
6825                "        > subdir2",
6826                "    > dir2/single_file",
6827            ],
6828            "Initial state with some dirs expanded"
6829        );
6830
6831        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6832        panel.update(cx, |panel, cx| {
6833            let project = panel.project.read(cx);
6834            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6835            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6836        });
6837
6838        toggle_expand_dir(&panel, "root/dir1", cx);
6839
6840        assert_eq!(
6841            visible_entries_as_strings(&panel, 0..20, cx),
6842            &[
6843                "v root",
6844                "    v dir1  <== selected",
6845                "        > subdir1/nested1",
6846                "        > subdir2",
6847                "    > dir2/single_file",
6848            ],
6849            "Subdirs should be collapsed and folded with auto-fold enabled"
6850        );
6851    }
6852
6853    // Test 3: With auto-fold disabled
6854    {
6855        cx.update(|_, cx| {
6856            let settings = *ProjectPanelSettings::get_global(cx);
6857            ProjectPanelSettings::override_global(
6858                ProjectPanelSettings {
6859                    auto_fold_dirs: false,
6860                    ..settings
6861                },
6862                cx,
6863            );
6864        });
6865
6866        let panel = workspace.update_in(cx, ProjectPanel::new);
6867        cx.run_until_parked();
6868
6869        toggle_expand_dir(&panel, "root/dir1", cx);
6870        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6871        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6872
6873        assert_eq!(
6874            visible_entries_as_strings(&panel, 0..20, cx),
6875            &[
6876                "v root",
6877                "    v dir1",
6878                "        v subdir1",
6879                "            v nested1  <== selected",
6880                "                  file1.txt",
6881                "                  file2.txt",
6882                "        > subdir2",
6883                "    > dir2",
6884            ],
6885            "Initial state with some dirs expanded and auto-fold disabled"
6886        );
6887
6888        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6889        panel.update(cx, |panel, cx| {
6890            let project = panel.project.read(cx);
6891            let worktree = project.worktrees(cx).next().unwrap().read(cx);
6892            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6893        });
6894
6895        toggle_expand_dir(&panel, "root/dir1", cx);
6896
6897        assert_eq!(
6898            visible_entries_as_strings(&panel, 0..20, cx),
6899            &[
6900                "v root",
6901                "    v dir1  <== selected",
6902                "        > subdir1",
6903                "        > subdir2",
6904                "    > dir2",
6905            ],
6906            "Subdirs should be collapsed but not folded with auto-fold disabled"
6907        );
6908    }
6909}
6910
6911#[gpui::test]
6912async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
6913    init_test(cx);
6914
6915    let fs = FakeFs::new(cx.executor());
6916    fs.insert_tree(
6917        path!("/root"),
6918        json!({
6919            "dir1": {
6920                "subdir1": {
6921                    "nested1": {
6922                        "file1.txt": "",
6923                        "file2.txt": ""
6924                    },
6925                },
6926                "subdir2": {
6927                    "file3.txt": ""
6928                }
6929            },
6930            "dir2": {
6931                "file4.txt": ""
6932            }
6933        }),
6934    )
6935    .await;
6936
6937    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6938    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6939    let workspace = window
6940        .read_with(cx, |mw, _| mw.workspace().clone())
6941        .unwrap();
6942    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6943
6944    let panel = workspace.update_in(cx, ProjectPanel::new);
6945    cx.run_until_parked();
6946
6947    toggle_expand_dir(&panel, "root/dir1", cx);
6948    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6949    toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6950    toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6951    toggle_expand_dir(&panel, "root/dir2", cx);
6952
6953    assert_eq!(
6954        visible_entries_as_strings(&panel, 0..20, cx),
6955        &[
6956            "v root",
6957            "    v dir1",
6958            "        v subdir1",
6959            "            v nested1",
6960            "                  file1.txt",
6961            "                  file2.txt",
6962            "        v subdir2",
6963            "              file3.txt",
6964            "    v dir2  <== selected",
6965            "          file4.txt",
6966        ],
6967        "Initial state with directories expanded"
6968    );
6969
6970    select_path(&panel, "root/dir1", cx);
6971    cx.run_until_parked();
6972
6973    panel.update_in(cx, |panel, window, cx| {
6974        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6975    });
6976    cx.run_until_parked();
6977
6978    assert_eq!(
6979        visible_entries_as_strings(&panel, 0..20, cx),
6980        &[
6981            "v root",
6982            "    > dir1  <== selected",
6983            "    v dir2",
6984            "          file4.txt",
6985        ],
6986        "dir1 and all its children should be collapsed, dir2 should remain expanded"
6987    );
6988
6989    toggle_expand_dir(&panel, "root/dir1", cx);
6990    cx.run_until_parked();
6991
6992    assert_eq!(
6993        visible_entries_as_strings(&panel, 0..20, cx),
6994        &[
6995            "v root",
6996            "    v dir1  <== selected",
6997            "        > subdir1",
6998            "        > subdir2",
6999            "    v dir2",
7000            "          file4.txt",
7001        ],
7002        "After re-expanding dir1, its children should still be collapsed"
7003    );
7004}
7005
7006#[gpui::test]
7007async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
7008    init_test(cx);
7009
7010    let fs = FakeFs::new(cx.executor());
7011    fs.insert_tree(
7012        path!("/root"),
7013        json!({
7014            "dir1": {
7015                "subdir1": {
7016                    "file1.txt": ""
7017                },
7018                "file2.txt": ""
7019            },
7020            "dir2": {
7021                "file3.txt": ""
7022            }
7023        }),
7024    )
7025    .await;
7026
7027    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7028    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7029    let workspace = window
7030        .read_with(cx, |mw, _| mw.workspace().clone())
7031        .unwrap();
7032    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7033
7034    let panel = workspace.update_in(cx, ProjectPanel::new);
7035    cx.run_until_parked();
7036
7037    toggle_expand_dir(&panel, "root/dir1", cx);
7038    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7039    toggle_expand_dir(&panel, "root/dir2", cx);
7040
7041    assert_eq!(
7042        visible_entries_as_strings(&panel, 0..20, cx),
7043        &[
7044            "v root",
7045            "    v dir1",
7046            "        v subdir1",
7047            "              file1.txt",
7048            "          file2.txt",
7049            "    v dir2  <== selected",
7050            "          file3.txt",
7051        ],
7052        "Initial state with directories expanded"
7053    );
7054
7055    // Select the root and collapse it and its children
7056    select_path(&panel, "root", cx);
7057    cx.run_until_parked();
7058
7059    panel.update_in(cx, |panel, window, cx| {
7060        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7061    });
7062    cx.run_until_parked();
7063
7064    // The root and all its children should be collapsed
7065    assert_eq!(
7066        visible_entries_as_strings(&panel, 0..20, cx),
7067        &["> root  <== selected"],
7068        "Root and all children should be collapsed"
7069    );
7070
7071    // Re-expand root and dir1, verify children were recursively collapsed
7072    toggle_expand_dir(&panel, "root", cx);
7073    toggle_expand_dir(&panel, "root/dir1", cx);
7074    cx.run_until_parked();
7075
7076    assert_eq!(
7077        visible_entries_as_strings(&panel, 0..20, cx),
7078        &[
7079            "v root",
7080            "    v dir1  <== selected",
7081            "        > subdir1",
7082            "          file2.txt",
7083            "    > dir2",
7084        ],
7085        "After re-expanding root and dir1, subdir1 should still be collapsed"
7086    );
7087}
7088
7089#[gpui::test]
7090async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7091    init_test(cx);
7092
7093    let fs = FakeFs::new(cx.executor());
7094    fs.insert_tree(
7095        path!("/root1"),
7096        json!({
7097            "dir1": {
7098                "subdir1": {
7099                    "file1.txt": ""
7100                },
7101                "file2.txt": ""
7102            }
7103        }),
7104    )
7105    .await;
7106    fs.insert_tree(
7107        path!("/root2"),
7108        json!({
7109            "dir2": {
7110                "file3.txt": ""
7111            },
7112            "file4.txt": ""
7113        }),
7114    )
7115    .await;
7116
7117    let project = Project::test(
7118        fs.clone(),
7119        [path!("/root1").as_ref(), path!("/root2").as_ref()],
7120        cx,
7121    )
7122    .await;
7123    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7124    let workspace = window
7125        .read_with(cx, |mw, _| mw.workspace().clone())
7126        .unwrap();
7127    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7128
7129    let panel = workspace.update_in(cx, ProjectPanel::new);
7130    cx.run_until_parked();
7131
7132    toggle_expand_dir(&panel, "root1/dir1", cx);
7133    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7134    toggle_expand_dir(&panel, "root2/dir2", cx);
7135
7136    assert_eq!(
7137        visible_entries_as_strings(&panel, 0..20, cx),
7138        &[
7139            "v root1",
7140            "    v dir1",
7141            "        v subdir1",
7142            "              file1.txt",
7143            "          file2.txt",
7144            "v root2",
7145            "    v dir2  <== selected",
7146            "          file3.txt",
7147            "      file4.txt",
7148        ],
7149        "Initial state with directories expanded across worktrees"
7150    );
7151
7152    // Select root1 and collapse it and its children.
7153    // In a multi-worktree project, this should only collapse the selected worktree,
7154    // leaving other worktrees unaffected.
7155    select_path(&panel, "root1", cx);
7156    cx.run_until_parked();
7157
7158    panel.update_in(cx, |panel, window, cx| {
7159        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7160    });
7161    cx.run_until_parked();
7162
7163    assert_eq!(
7164        visible_entries_as_strings(&panel, 0..20, cx),
7165        &[
7166            "> root1  <== selected",
7167            "v root2",
7168            "    v dir2",
7169            "          file3.txt",
7170            "      file4.txt",
7171        ],
7172        "Only root1 should be collapsed, root2 should remain expanded"
7173    );
7174
7175    // Re-expand root1 and verify its children were recursively collapsed
7176    toggle_expand_dir(&panel, "root1", cx);
7177
7178    assert_eq!(
7179        visible_entries_as_strings(&panel, 0..20, cx),
7180        &[
7181            "v root1  <== selected",
7182            "    > dir1",
7183            "v root2",
7184            "    v dir2",
7185            "          file3.txt",
7186            "      file4.txt",
7187        ],
7188        "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
7189    );
7190}
7191
7192#[gpui::test]
7193async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7194    init_test(cx);
7195
7196    let fs = FakeFs::new(cx.executor());
7197    fs.insert_tree(
7198        path!("/root1"),
7199        json!({
7200            "dir1": {
7201                "subdir1": {
7202                    "file1.txt": ""
7203                },
7204                "file2.txt": ""
7205            }
7206        }),
7207    )
7208    .await;
7209    fs.insert_tree(
7210        path!("/root2"),
7211        json!({
7212            "dir2": {
7213                "subdir2": {
7214                    "file3.txt": ""
7215                },
7216                "file4.txt": ""
7217            }
7218        }),
7219    )
7220    .await;
7221
7222    let project = Project::test(
7223        fs.clone(),
7224        [path!("/root1").as_ref(), path!("/root2").as_ref()],
7225        cx,
7226    )
7227    .await;
7228    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7229    let workspace = window
7230        .read_with(cx, |mw, _| mw.workspace().clone())
7231        .unwrap();
7232    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7233
7234    let panel = workspace.update_in(cx, ProjectPanel::new);
7235    cx.run_until_parked();
7236
7237    toggle_expand_dir(&panel, "root1/dir1", cx);
7238    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7239    toggle_expand_dir(&panel, "root2/dir2", cx);
7240    toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
7241
7242    assert_eq!(
7243        visible_entries_as_strings(&panel, 0..20, cx),
7244        &[
7245            "v root1",
7246            "    v dir1",
7247            "        v subdir1",
7248            "              file1.txt",
7249            "          file2.txt",
7250            "v root2",
7251            "    v dir2",
7252            "        v subdir2  <== selected",
7253            "              file3.txt",
7254            "          file4.txt",
7255        ],
7256        "Initial state with directories expanded across worktrees"
7257    );
7258
7259    // Select dir1 in root1 and collapse it
7260    select_path(&panel, "root1/dir1", cx);
7261    cx.run_until_parked();
7262
7263    panel.update_in(cx, |panel, window, cx| {
7264        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7265    });
7266    cx.run_until_parked();
7267
7268    assert_eq!(
7269        visible_entries_as_strings(&panel, 0..20, cx),
7270        &[
7271            "v root1",
7272            "    > dir1  <== selected",
7273            "v root2",
7274            "    v dir2",
7275            "        v subdir2",
7276            "              file3.txt",
7277            "          file4.txt",
7278        ],
7279        "Only dir1 should be collapsed, root2 should be completely unaffected"
7280    );
7281
7282    // Re-expand dir1 and verify subdir1 was recursively collapsed
7283    toggle_expand_dir(&panel, "root1/dir1", cx);
7284
7285    assert_eq!(
7286        visible_entries_as_strings(&panel, 0..20, cx),
7287        &[
7288            "v root1",
7289            "    v dir1  <== selected",
7290            "        > subdir1",
7291            "          file2.txt",
7292            "v root2",
7293            "    v dir2",
7294            "        v subdir2",
7295            "              file3.txt",
7296            "          file4.txt",
7297        ],
7298        "After re-expanding dir1, subdir1 should still be collapsed"
7299    );
7300}
7301
7302#[gpui::test]
7303async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
7304    init_test(cx);
7305
7306    let fs = FakeFs::new(cx.executor());
7307    fs.insert_tree(
7308        path!("/root"),
7309        json!({
7310            "dir1": {
7311                "subdir1": {
7312                    "file1.txt": ""
7313                },
7314                "file2.txt": ""
7315            },
7316            "dir2": {
7317                "file3.txt": ""
7318            }
7319        }),
7320    )
7321    .await;
7322
7323    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7324    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7325    let workspace = window
7326        .read_with(cx, |mw, _| mw.workspace().clone())
7327        .unwrap();
7328    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7329
7330    let panel = workspace.update_in(cx, ProjectPanel::new);
7331    cx.run_until_parked();
7332
7333    toggle_expand_dir(&panel, "root/dir1", cx);
7334    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7335    toggle_expand_dir(&panel, "root/dir2", cx);
7336
7337    assert_eq!(
7338        visible_entries_as_strings(&panel, 0..20, cx),
7339        &[
7340            "v root",
7341            "    v dir1",
7342            "        v subdir1",
7343            "              file1.txt",
7344            "          file2.txt",
7345            "    v dir2  <== selected",
7346            "          file3.txt",
7347        ],
7348        "Initial state with directories expanded"
7349    );
7350
7351    select_path(&panel, "root", cx);
7352    cx.run_until_parked();
7353
7354    panel.update_in(cx, |panel, window, cx| {
7355        panel.collapse_all_for_root(window, cx);
7356    });
7357    cx.run_until_parked();
7358
7359    assert_eq!(
7360        visible_entries_as_strings(&panel, 0..20, cx),
7361        &["v root  <== selected", "    > dir1", "    > dir2"],
7362        "Root should remain expanded but all children should be collapsed"
7363    );
7364
7365    toggle_expand_dir(&panel, "root/dir1", cx);
7366    cx.run_until_parked();
7367
7368    assert_eq!(
7369        visible_entries_as_strings(&panel, 0..20, cx),
7370        &[
7371            "v root",
7372            "    v dir1  <== selected",
7373            "        > subdir1",
7374            "          file2.txt",
7375            "    > dir2",
7376        ],
7377        "After re-expanding dir1, subdir1 should still be collapsed"
7378    );
7379}
7380
7381#[gpui::test]
7382async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7383    init_test(cx);
7384
7385    let fs = FakeFs::new(cx.executor());
7386    fs.insert_tree(
7387        path!("/root1"),
7388        json!({
7389            "dir1": {
7390                "subdir1": {
7391                    "file1.txt": ""
7392                },
7393                "file2.txt": ""
7394            }
7395        }),
7396    )
7397    .await;
7398    fs.insert_tree(
7399        path!("/root2"),
7400        json!({
7401            "dir2": {
7402                "file3.txt": ""
7403            },
7404            "file4.txt": ""
7405        }),
7406    )
7407    .await;
7408
7409    let project = Project::test(
7410        fs.clone(),
7411        [path!("/root1").as_ref(), path!("/root2").as_ref()],
7412        cx,
7413    )
7414    .await;
7415    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7416    let workspace = window
7417        .read_with(cx, |mw, _| mw.workspace().clone())
7418        .unwrap();
7419    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7420
7421    let panel = workspace.update_in(cx, ProjectPanel::new);
7422    cx.run_until_parked();
7423
7424    toggle_expand_dir(&panel, "root1/dir1", cx);
7425    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7426    toggle_expand_dir(&panel, "root2/dir2", cx);
7427
7428    assert_eq!(
7429        visible_entries_as_strings(&panel, 0..20, cx),
7430        &[
7431            "v root1",
7432            "    v dir1",
7433            "        v subdir1",
7434            "              file1.txt",
7435            "          file2.txt",
7436            "v root2",
7437            "    v dir2  <== selected",
7438            "          file3.txt",
7439            "      file4.txt",
7440        ],
7441        "Initial state with directories expanded across worktrees"
7442    );
7443
7444    select_path(&panel, "root1", cx);
7445    cx.run_until_parked();
7446
7447    panel.update_in(cx, |panel, window, cx| {
7448        panel.collapse_all_for_root(window, cx);
7449    });
7450    cx.run_until_parked();
7451
7452    assert_eq!(
7453        visible_entries_as_strings(&panel, 0..20, cx),
7454        &[
7455            "> root1  <== selected",
7456            "v root2",
7457            "    v dir2",
7458            "          file3.txt",
7459            "      file4.txt",
7460        ],
7461        "With multiple worktrees, root1 should collapse completely (including itself)"
7462    );
7463}
7464
7465#[gpui::test]
7466async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
7467    init_test(cx);
7468
7469    let fs = FakeFs::new(cx.executor());
7470    fs.insert_tree(
7471        path!("/root"),
7472        json!({
7473            "dir1": {
7474                "subdir1": {
7475                    "file1.txt": ""
7476                },
7477            },
7478            "dir2": {
7479                "file2.txt": ""
7480            }
7481        }),
7482    )
7483    .await;
7484
7485    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7486    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7487    let workspace = window
7488        .read_with(cx, |mw, _| mw.workspace().clone())
7489        .unwrap();
7490    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7491
7492    let panel = workspace.update_in(cx, ProjectPanel::new);
7493    cx.run_until_parked();
7494
7495    toggle_expand_dir(&panel, "root/dir1", cx);
7496    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7497    toggle_expand_dir(&panel, "root/dir2", cx);
7498
7499    assert_eq!(
7500        visible_entries_as_strings(&panel, 0..20, cx),
7501        &[
7502            "v root",
7503            "    v dir1",
7504            "        v subdir1",
7505            "              file1.txt",
7506            "    v dir2  <== selected",
7507            "          file2.txt",
7508        ],
7509        "Initial state with directories expanded"
7510    );
7511
7512    select_path(&panel, "root/dir1", cx);
7513    cx.run_until_parked();
7514
7515    panel.update_in(cx, |panel, window, cx| {
7516        panel.collapse_all_for_root(window, cx);
7517    });
7518    cx.run_until_parked();
7519
7520    assert_eq!(
7521        visible_entries_as_strings(&panel, 0..20, cx),
7522        &[
7523            "v root",
7524            "    v dir1  <== selected",
7525            "        v subdir1",
7526            "              file1.txt",
7527            "    v dir2",
7528            "          file2.txt",
7529        ],
7530        "collapse_all_for_root should be a no-op when called on a non-root directory"
7531    );
7532}
7533
7534#[gpui::test]
7535async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
7536    init_test(cx);
7537
7538    let fs = FakeFs::new(cx.executor());
7539    fs.insert_tree(
7540        path!("/root"),
7541        json!({
7542            "dir1": {
7543                "file1.txt": "",
7544            },
7545        }),
7546    )
7547    .await;
7548
7549    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7550    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7551    let workspace = window
7552        .read_with(cx, |mw, _| mw.workspace().clone())
7553        .unwrap();
7554    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7555
7556    let panel = workspace.update_in(cx, |workspace, window, cx| {
7557        let panel = ProjectPanel::new(workspace, window, cx);
7558        workspace.add_panel(panel.clone(), window, cx);
7559        panel
7560    });
7561    cx.run_until_parked();
7562
7563    #[rustfmt::skip]
7564    assert_eq!(
7565        visible_entries_as_strings(&panel, 0..20, cx),
7566        &[
7567            "v root",
7568            "    > dir1",
7569        ],
7570        "Initial state with nothing selected"
7571    );
7572
7573    panel.update_in(cx, |panel, window, cx| {
7574        panel.new_file(&NewFile, window, cx);
7575    });
7576    cx.run_until_parked();
7577    panel.update_in(cx, |panel, window, cx| {
7578        assert!(panel.filename_editor.read(cx).is_focused(window));
7579    });
7580    panel
7581        .update_in(cx, |panel, window, cx| {
7582            panel.filename_editor.update(cx, |editor, cx| {
7583                editor.set_text("hello_from_no_selections", window, cx)
7584            });
7585            panel.confirm_edit(true, window, cx).unwrap()
7586        })
7587        .await
7588        .unwrap();
7589    cx.run_until_parked();
7590    #[rustfmt::skip]
7591    assert_eq!(
7592        visible_entries_as_strings(&panel, 0..20, cx),
7593        &[
7594            "v root",
7595            "    > dir1",
7596            "      hello_from_no_selections  <== selected  <== marked",
7597        ],
7598        "A new file is created under the root directory"
7599    );
7600}
7601
7602#[gpui::test]
7603async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
7604    init_test(cx);
7605
7606    let fs = FakeFs::new(cx.executor());
7607    fs.insert_tree(
7608        path!("/root"),
7609        json!({
7610            "existing_dir": {
7611                "existing_file.txt": "",
7612            },
7613            "existing_file.txt": "",
7614        }),
7615    )
7616    .await;
7617
7618    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7619    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7620    let workspace = window
7621        .read_with(cx, |mw, _| mw.workspace().clone())
7622        .unwrap();
7623    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7624
7625    cx.update(|_, cx| {
7626        let settings = *ProjectPanelSettings::get_global(cx);
7627        ProjectPanelSettings::override_global(
7628            ProjectPanelSettings {
7629                hide_root: true,
7630                ..settings
7631            },
7632            cx,
7633        );
7634    });
7635
7636    let panel = workspace.update_in(cx, |workspace, window, cx| {
7637        let panel = ProjectPanel::new(workspace, window, cx);
7638        workspace.add_panel(panel.clone(), window, cx);
7639        panel
7640    });
7641    cx.run_until_parked();
7642
7643    #[rustfmt::skip]
7644    assert_eq!(
7645        visible_entries_as_strings(&panel, 0..20, cx),
7646        &[
7647            "> existing_dir",
7648            "  existing_file.txt",
7649        ],
7650        "Initial state with hide_root=true, root should be hidden and nothing selected"
7651    );
7652
7653    panel.update(cx, |panel, _| {
7654        assert!(
7655            panel.selection.is_none(),
7656            "Should have no selection initially"
7657        );
7658    });
7659
7660    // Test 1: Create new file when no entry is selected
7661    panel.update_in(cx, |panel, window, cx| {
7662        panel.new_file(&NewFile, window, cx);
7663    });
7664    cx.run_until_parked();
7665    panel.update_in(cx, |panel, window, cx| {
7666        assert!(panel.filename_editor.read(cx).is_focused(window));
7667    });
7668    cx.run_until_parked();
7669    #[rustfmt::skip]
7670    assert_eq!(
7671        visible_entries_as_strings(&panel, 0..20, cx),
7672        &[
7673            "> existing_dir",
7674            "  [EDITOR: '']  <== selected",
7675            "  existing_file.txt",
7676        ],
7677        "Editor should appear at root level when hide_root=true and no selection"
7678    );
7679
7680    let confirm = panel.update_in(cx, |panel, window, cx| {
7681        panel.filename_editor.update(cx, |editor, cx| {
7682            editor.set_text("new_file_at_root.txt", window, cx)
7683        });
7684        panel.confirm_edit(true, window, cx).unwrap()
7685    });
7686    confirm.await.unwrap();
7687    cx.run_until_parked();
7688
7689    #[rustfmt::skip]
7690    assert_eq!(
7691        visible_entries_as_strings(&panel, 0..20, cx),
7692        &[
7693            "> existing_dir",
7694            "  existing_file.txt",
7695            "  new_file_at_root.txt  <== selected  <== marked",
7696        ],
7697        "New file should be created at root level and visible without root prefix"
7698    );
7699
7700    assert!(
7701        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
7702        "File should be created in the actual root directory"
7703    );
7704
7705    // Test 2: Create new directory when no entry is selected
7706    panel.update(cx, |panel, _| {
7707        panel.selection = None;
7708    });
7709
7710    panel.update_in(cx, |panel, window, cx| {
7711        panel.new_directory(&NewDirectory, window, cx);
7712    });
7713    cx.run_until_parked();
7714
7715    panel.update_in(cx, |panel, window, cx| {
7716        assert!(panel.filename_editor.read(cx).is_focused(window));
7717    });
7718
7719    #[rustfmt::skip]
7720    assert_eq!(
7721        visible_entries_as_strings(&panel, 0..20, cx),
7722        &[
7723            "> [EDITOR: '']  <== selected",
7724            "> existing_dir",
7725            "  existing_file.txt",
7726            "  new_file_at_root.txt",
7727        ],
7728        "Directory editor should appear at root level when hide_root=true and no selection"
7729    );
7730
7731    let confirm = panel.update_in(cx, |panel, window, cx| {
7732        panel.filename_editor.update(cx, |editor, cx| {
7733            editor.set_text("new_dir_at_root", window, cx)
7734        });
7735        panel.confirm_edit(true, window, cx).unwrap()
7736    });
7737    confirm.await.unwrap();
7738    cx.run_until_parked();
7739
7740    #[rustfmt::skip]
7741    assert_eq!(
7742        visible_entries_as_strings(&panel, 0..20, cx),
7743        &[
7744            "> existing_dir",
7745            "v new_dir_at_root  <== selected",
7746            "  existing_file.txt",
7747            "  new_file_at_root.txt",
7748        ],
7749        "New directory should be created at root level and visible without root prefix"
7750    );
7751
7752    assert!(
7753        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
7754        "Directory should be created in the actual root directory"
7755    );
7756}
7757
7758#[cfg(windows)]
7759#[gpui::test]
7760async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
7761    init_test(cx);
7762
7763    let fs = FakeFs::new(cx.executor());
7764    fs.insert_tree(
7765        path!("/root"),
7766        json!({
7767            "dir1": {
7768                "file1.txt": "",
7769            },
7770        }),
7771    )
7772    .await;
7773
7774    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7775    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7776    let workspace = window
7777        .read_with(cx, |mw, _| mw.workspace().clone())
7778        .unwrap();
7779    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7780
7781    let panel = workspace.update_in(cx, |workspace, window, cx| {
7782        let panel = ProjectPanel::new(workspace, window, cx);
7783        workspace.add_panel(panel.clone(), window, cx);
7784        panel
7785    });
7786    cx.run_until_parked();
7787
7788    #[rustfmt::skip]
7789    assert_eq!(
7790        visible_entries_as_strings(&panel, 0..20, cx),
7791        &[
7792            "v root",
7793            "    > dir1",
7794        ],
7795        "Initial state with nothing selected"
7796    );
7797
7798    panel.update_in(cx, |panel, window, cx| {
7799        panel.new_file(&NewFile, window, cx);
7800    });
7801    cx.run_until_parked();
7802    panel.update_in(cx, |panel, window, cx| {
7803        assert!(panel.filename_editor.read(cx).is_focused(window));
7804    });
7805    panel
7806        .update_in(cx, |panel, window, cx| {
7807            panel
7808                .filename_editor
7809                .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
7810            panel.confirm_edit(true, window, cx).unwrap()
7811        })
7812        .await
7813        .unwrap();
7814    cx.run_until_parked();
7815    #[rustfmt::skip]
7816    assert_eq!(
7817        visible_entries_as_strings(&panel, 0..20, cx),
7818        &[
7819            "v root",
7820            "    > dir1",
7821            "      foo  <== selected  <== marked",
7822        ],
7823        "A new file is created under the root directory without the trailing dot"
7824    );
7825}
7826
7827#[gpui::test]
7828async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
7829    init_test(cx);
7830
7831    let fs = FakeFs::new(cx.executor());
7832    fs.insert_tree(
7833        "/root",
7834        json!({
7835            "dir1": {
7836                "file1.txt": "",
7837                "dir2": {
7838                    "file2.txt": ""
7839                }
7840            },
7841            "file3.txt": ""
7842        }),
7843    )
7844    .await;
7845
7846    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7847    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7848    let workspace = window
7849        .read_with(cx, |mw, _| mw.workspace().clone())
7850        .unwrap();
7851    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7852    let panel = workspace.update_in(cx, ProjectPanel::new);
7853    cx.run_until_parked();
7854
7855    panel.update(cx, |panel, cx| {
7856        let project = panel.project.read(cx);
7857        let worktree = project.visible_worktrees(cx).next().unwrap();
7858        let worktree = worktree.read(cx);
7859
7860        // Test 1: Target is a directory, should highlight the directory itself
7861        let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
7862        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
7863        assert_eq!(
7864            result,
7865            Some(dir_entry.id),
7866            "Should highlight directory itself"
7867        );
7868
7869        // Test 2: Target is nested file, should highlight immediate parent
7870        let nested_file = worktree
7871            .entry_for_path(rel_path("dir1/dir2/file2.txt"))
7872            .unwrap();
7873        let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
7874        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
7875        assert_eq!(
7876            result,
7877            Some(nested_parent.id),
7878            "Should highlight immediate parent"
7879        );
7880
7881        // Test 3: Target is root level file, should highlight root
7882        let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
7883        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
7884        assert_eq!(
7885            result,
7886            Some(worktree.root_entry().unwrap().id),
7887            "Root level file should return None"
7888        );
7889
7890        // Test 4: Target is root itself, should highlight root
7891        let root_entry = worktree.root_entry().unwrap();
7892        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
7893        assert_eq!(
7894            result,
7895            Some(root_entry.id),
7896            "Root level file should return None"
7897        );
7898    });
7899}
7900
7901#[gpui::test]
7902async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
7903    init_test(cx);
7904
7905    let fs = FakeFs::new(cx.executor());
7906    fs.insert_tree(
7907        "/root",
7908        json!({
7909            "parent_dir": {
7910                "child_file.txt": "",
7911                "sibling_file.txt": "",
7912                "child_dir": {
7913                    "nested_file.txt": ""
7914                }
7915            },
7916            "other_dir": {
7917                "other_file.txt": ""
7918            }
7919        }),
7920    )
7921    .await;
7922
7923    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7924    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7925    let workspace = window
7926        .read_with(cx, |mw, _| mw.workspace().clone())
7927        .unwrap();
7928    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7929    let panel = workspace.update_in(cx, ProjectPanel::new);
7930    cx.run_until_parked();
7931
7932    panel.update(cx, |panel, cx| {
7933        let project = panel.project.read(cx);
7934        let worktree = project.visible_worktrees(cx).next().unwrap();
7935        let worktree_id = worktree.read(cx).id();
7936        let worktree = worktree.read(cx);
7937
7938        let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
7939        let child_file = worktree
7940            .entry_for_path(rel_path("parent_dir/child_file.txt"))
7941            .unwrap();
7942        let sibling_file = worktree
7943            .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
7944            .unwrap();
7945        let child_dir = worktree
7946            .entry_for_path(rel_path("parent_dir/child_dir"))
7947            .unwrap();
7948        let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
7949        let other_file = worktree
7950            .entry_for_path(rel_path("other_dir/other_file.txt"))
7951            .unwrap();
7952
7953        // Test 1: Single item drag, don't highlight parent directory
7954        let dragged_selection = DraggedSelection {
7955            active_selection: SelectedEntry {
7956                worktree_id,
7957                entry_id: child_file.id,
7958            },
7959            marked_selections: Arc::new([SelectedEntry {
7960                worktree_id,
7961                entry_id: child_file.id,
7962            }]),
7963        };
7964        let result =
7965            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7966        assert_eq!(result, None, "Should not highlight parent of dragged item");
7967
7968        // Test 2: Single item drag, don't highlight sibling files
7969        let result = panel.highlight_entry_for_selection_drag(
7970            sibling_file,
7971            worktree,
7972            &dragged_selection,
7973            cx,
7974        );
7975        assert_eq!(result, None, "Should not highlight sibling files");
7976
7977        // Test 3: Single item drag, highlight unrelated directory
7978        let result =
7979            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
7980        assert_eq!(
7981            result,
7982            Some(other_dir.id),
7983            "Should highlight unrelated directory"
7984        );
7985
7986        // Test 4: Single item drag, highlight sibling directory
7987        let result =
7988            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7989        assert_eq!(
7990            result,
7991            Some(child_dir.id),
7992            "Should highlight sibling directory"
7993        );
7994
7995        // Test 5: Multiple items drag, highlight parent directory
7996        let dragged_selection = DraggedSelection {
7997            active_selection: SelectedEntry {
7998                worktree_id,
7999                entry_id: child_file.id,
8000            },
8001            marked_selections: Arc::new([
8002                SelectedEntry {
8003                    worktree_id,
8004                    entry_id: child_file.id,
8005                },
8006                SelectedEntry {
8007                    worktree_id,
8008                    entry_id: sibling_file.id,
8009                },
8010            ]),
8011        };
8012        let result =
8013            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
8014        assert_eq!(
8015            result,
8016            Some(parent_dir.id),
8017            "Should highlight parent with multiple items"
8018        );
8019
8020        // Test 6: Target is file in different directory, highlight parent
8021        let result =
8022            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
8023        assert_eq!(
8024            result,
8025            Some(other_dir.id),
8026            "Should highlight parent of target file"
8027        );
8028
8029        // Test 7: Target is directory, always highlight
8030        let result =
8031            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
8032        assert_eq!(
8033            result,
8034            Some(child_dir.id),
8035            "Should always highlight directories"
8036        );
8037    });
8038}
8039
8040#[gpui::test]
8041async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
8042    init_test(cx);
8043
8044    let fs = FakeFs::new(cx.executor());
8045    fs.insert_tree(
8046        "/root1",
8047        json!({
8048            "src": {
8049                "main.rs": "",
8050                "lib.rs": ""
8051            }
8052        }),
8053    )
8054    .await;
8055    fs.insert_tree(
8056        "/root2",
8057        json!({
8058            "src": {
8059                "main.rs": "",
8060                "test.rs": ""
8061            }
8062        }),
8063    )
8064    .await;
8065
8066    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8067    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8068    let workspace = window
8069        .read_with(cx, |mw, _| mw.workspace().clone())
8070        .unwrap();
8071    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8072    let panel = workspace.update_in(cx, ProjectPanel::new);
8073    cx.run_until_parked();
8074
8075    panel.update(cx, |panel, cx| {
8076        let project = panel.project.read(cx);
8077        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8078
8079        let worktree_a = &worktrees[0];
8080        let main_rs_from_a = worktree_a
8081            .read(cx)
8082            .entry_for_path(rel_path("src/main.rs"))
8083            .unwrap();
8084
8085        let worktree_b = &worktrees[1];
8086        let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
8087        let main_rs_from_b = worktree_b
8088            .read(cx)
8089            .entry_for_path(rel_path("src/main.rs"))
8090            .unwrap();
8091
8092        // Test dragging file from worktree A onto parent of file with same relative path in worktree B
8093        let dragged_selection = DraggedSelection {
8094            active_selection: SelectedEntry {
8095                worktree_id: worktree_a.read(cx).id(),
8096                entry_id: main_rs_from_a.id,
8097            },
8098            marked_selections: Arc::new([SelectedEntry {
8099                worktree_id: worktree_a.read(cx).id(),
8100                entry_id: main_rs_from_a.id,
8101            }]),
8102        };
8103
8104        let result = panel.highlight_entry_for_selection_drag(
8105            src_dir_from_b,
8106            worktree_b.read(cx),
8107            &dragged_selection,
8108            cx,
8109        );
8110        assert_eq!(
8111            result,
8112            Some(src_dir_from_b.id),
8113            "Should highlight target directory from different worktree even with same relative path"
8114        );
8115
8116        // Test dragging file from worktree A onto file with same relative path in worktree B
8117        let result = panel.highlight_entry_for_selection_drag(
8118            main_rs_from_b,
8119            worktree_b.read(cx),
8120            &dragged_selection,
8121            cx,
8122        );
8123        assert_eq!(
8124            result,
8125            Some(src_dir_from_b.id),
8126            "Should highlight parent of target file from different worktree"
8127        );
8128    });
8129}
8130
8131#[gpui::test]
8132async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
8133    init_test(cx);
8134
8135    let fs = FakeFs::new(cx.executor());
8136    fs.insert_tree(
8137        "/root1",
8138        json!({
8139            "parent_dir": {
8140                "child_file.txt": "",
8141                "nested_dir": {
8142                    "nested_file.txt": ""
8143                }
8144            },
8145            "root_file.txt": ""
8146        }),
8147    )
8148    .await;
8149
8150    fs.insert_tree(
8151        "/root2",
8152        json!({
8153            "other_dir": {
8154                "other_file.txt": ""
8155            }
8156        }),
8157    )
8158    .await;
8159
8160    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8161    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8162    let workspace = window
8163        .read_with(cx, |mw, _| mw.workspace().clone())
8164        .unwrap();
8165    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8166    let panel = workspace.update_in(cx, ProjectPanel::new);
8167    cx.run_until_parked();
8168
8169    panel.update(cx, |panel, cx| {
8170        let project = panel.project.read(cx);
8171        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8172        let worktree1 = worktrees[0].read(cx);
8173        let worktree2 = worktrees[1].read(cx);
8174        let worktree1_id = worktree1.id();
8175        let _worktree2_id = worktree2.id();
8176
8177        let root1_entry = worktree1.root_entry().unwrap();
8178        let root2_entry = worktree2.root_entry().unwrap();
8179        let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
8180        let child_file = worktree1
8181            .entry_for_path(rel_path("parent_dir/child_file.txt"))
8182            .unwrap();
8183        let nested_file = worktree1
8184            .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
8185            .unwrap();
8186        let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
8187
8188        // Test 1: Multiple entries - should always highlight background
8189        let multiple_dragged_selection = DraggedSelection {
8190            active_selection: SelectedEntry {
8191                worktree_id: worktree1_id,
8192                entry_id: child_file.id,
8193            },
8194            marked_selections: Arc::new([
8195                SelectedEntry {
8196                    worktree_id: worktree1_id,
8197                    entry_id: child_file.id,
8198                },
8199                SelectedEntry {
8200                    worktree_id: worktree1_id,
8201                    entry_id: nested_file.id,
8202                },
8203            ]),
8204        };
8205
8206        let result = panel.should_highlight_background_for_selection_drag(
8207            &multiple_dragged_selection,
8208            root1_entry.id,
8209            cx,
8210        );
8211        assert!(result, "Should highlight background for multiple entries");
8212
8213        // Test 2: Single entry with non-empty parent path - should highlight background
8214        let nested_dragged_selection = DraggedSelection {
8215            active_selection: SelectedEntry {
8216                worktree_id: worktree1_id,
8217                entry_id: nested_file.id,
8218            },
8219            marked_selections: Arc::new([SelectedEntry {
8220                worktree_id: worktree1_id,
8221                entry_id: nested_file.id,
8222            }]),
8223        };
8224
8225        let result = panel.should_highlight_background_for_selection_drag(
8226            &nested_dragged_selection,
8227            root1_entry.id,
8228            cx,
8229        );
8230        assert!(result, "Should highlight background for nested file");
8231
8232        // Test 3: Single entry at root level, same worktree - should NOT highlight background
8233        let root_file_dragged_selection = DraggedSelection {
8234            active_selection: SelectedEntry {
8235                worktree_id: worktree1_id,
8236                entry_id: root_file.id,
8237            },
8238            marked_selections: Arc::new([SelectedEntry {
8239                worktree_id: worktree1_id,
8240                entry_id: root_file.id,
8241            }]),
8242        };
8243
8244        let result = panel.should_highlight_background_for_selection_drag(
8245            &root_file_dragged_selection,
8246            root1_entry.id,
8247            cx,
8248        );
8249        assert!(
8250            !result,
8251            "Should NOT highlight background for root file in same worktree"
8252        );
8253
8254        // Test 4: Single entry at root level, different worktree - should highlight background
8255        let result = panel.should_highlight_background_for_selection_drag(
8256            &root_file_dragged_selection,
8257            root2_entry.id,
8258            cx,
8259        );
8260        assert!(
8261            result,
8262            "Should highlight background for root file from different worktree"
8263        );
8264
8265        // Test 5: Single entry in subdirectory - should highlight background
8266        let child_file_dragged_selection = DraggedSelection {
8267            active_selection: SelectedEntry {
8268                worktree_id: worktree1_id,
8269                entry_id: child_file.id,
8270            },
8271            marked_selections: Arc::new([SelectedEntry {
8272                worktree_id: worktree1_id,
8273                entry_id: child_file.id,
8274            }]),
8275        };
8276
8277        let result = panel.should_highlight_background_for_selection_drag(
8278            &child_file_dragged_selection,
8279            root1_entry.id,
8280            cx,
8281        );
8282        assert!(
8283            result,
8284            "Should highlight background for file with non-empty parent path"
8285        );
8286    });
8287}
8288
8289#[gpui::test]
8290async fn test_hide_root(cx: &mut gpui::TestAppContext) {
8291    init_test(cx);
8292
8293    let fs = FakeFs::new(cx.executor());
8294    fs.insert_tree(
8295        "/root1",
8296        json!({
8297            "dir1": {
8298                "file1.txt": "content",
8299                "file2.txt": "content",
8300            },
8301            "dir2": {
8302                "file3.txt": "content",
8303            },
8304            "file4.txt": "content",
8305        }),
8306    )
8307    .await;
8308
8309    fs.insert_tree(
8310        "/root2",
8311        json!({
8312            "dir3": {
8313                "file5.txt": "content",
8314            },
8315            "file6.txt": "content",
8316        }),
8317    )
8318    .await;
8319
8320    // Test 1: Single worktree with hide_root = false
8321    {
8322        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8323        let window =
8324            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8325        let workspace = window
8326            .read_with(cx, |mw, _| mw.workspace().clone())
8327            .unwrap();
8328        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8329
8330        cx.update(|_, cx| {
8331            let settings = *ProjectPanelSettings::get_global(cx);
8332            ProjectPanelSettings::override_global(
8333                ProjectPanelSettings {
8334                    hide_root: false,
8335                    ..settings
8336                },
8337                cx,
8338            );
8339        });
8340
8341        let panel = workspace.update_in(cx, ProjectPanel::new);
8342        cx.run_until_parked();
8343
8344        #[rustfmt::skip]
8345        assert_eq!(
8346            visible_entries_as_strings(&panel, 0..10, cx),
8347            &[
8348                "v root1",
8349                "    > dir1",
8350                "    > dir2",
8351                "      file4.txt",
8352            ],
8353            "With hide_root=false and single worktree, root should be visible"
8354        );
8355    }
8356
8357    // Test 2: Single worktree with hide_root = true
8358    {
8359        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8360        let window =
8361            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8362        let workspace = window
8363            .read_with(cx, |mw, _| mw.workspace().clone())
8364            .unwrap();
8365        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8366
8367        // Set hide_root to true
8368        cx.update(|_, cx| {
8369            let settings = *ProjectPanelSettings::get_global(cx);
8370            ProjectPanelSettings::override_global(
8371                ProjectPanelSettings {
8372                    hide_root: true,
8373                    ..settings
8374                },
8375                cx,
8376            );
8377        });
8378
8379        let panel = workspace.update_in(cx, ProjectPanel::new);
8380        cx.run_until_parked();
8381
8382        assert_eq!(
8383            visible_entries_as_strings(&panel, 0..10, cx),
8384            &["> dir1", "> dir2", "  file4.txt",],
8385            "With hide_root=true and single worktree, root should be hidden"
8386        );
8387
8388        // Test expanding directories still works without root
8389        toggle_expand_dir(&panel, "root1/dir1", cx);
8390        assert_eq!(
8391            visible_entries_as_strings(&panel, 0..10, cx),
8392            &[
8393                "v dir1  <== selected",
8394                "      file1.txt",
8395                "      file2.txt",
8396                "> dir2",
8397                "  file4.txt",
8398            ],
8399            "Should be able to expand directories even when root is hidden"
8400        );
8401    }
8402
8403    // Test 3: Multiple worktrees with hide_root = true
8404    {
8405        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8406        let window =
8407            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8408        let workspace = window
8409            .read_with(cx, |mw, _| mw.workspace().clone())
8410            .unwrap();
8411        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8412
8413        // Set hide_root to true
8414        cx.update(|_, cx| {
8415            let settings = *ProjectPanelSettings::get_global(cx);
8416            ProjectPanelSettings::override_global(
8417                ProjectPanelSettings {
8418                    hide_root: true,
8419                    ..settings
8420                },
8421                cx,
8422            );
8423        });
8424
8425        let panel = workspace.update_in(cx, ProjectPanel::new);
8426        cx.run_until_parked();
8427
8428        assert_eq!(
8429            visible_entries_as_strings(&panel, 0..10, cx),
8430            &[
8431                "v root1",
8432                "    > dir1",
8433                "    > dir2",
8434                "      file4.txt",
8435                "v root2",
8436                "    > dir3",
8437                "      file6.txt",
8438            ],
8439            "With hide_root=true and multiple worktrees, roots should still be visible"
8440        );
8441    }
8442
8443    // Test 4: Multiple worktrees with hide_root = false
8444    {
8445        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8446        let window =
8447            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8448        let workspace = window
8449            .read_with(cx, |mw, _| mw.workspace().clone())
8450            .unwrap();
8451        let cx = &mut VisualTestContext::from_window(window.into(), cx);
8452
8453        cx.update(|_, cx| {
8454            let settings = *ProjectPanelSettings::get_global(cx);
8455            ProjectPanelSettings::override_global(
8456                ProjectPanelSettings {
8457                    hide_root: false,
8458                    ..settings
8459                },
8460                cx,
8461            );
8462        });
8463
8464        let panel = workspace.update_in(cx, ProjectPanel::new);
8465        cx.run_until_parked();
8466
8467        assert_eq!(
8468            visible_entries_as_strings(&panel, 0..10, cx),
8469            &[
8470                "v root1",
8471                "    > dir1",
8472                "    > dir2",
8473                "      file4.txt",
8474                "v root2",
8475                "    > dir3",
8476                "      file6.txt",
8477            ],
8478            "With hide_root=false and multiple worktrees, roots should be visible"
8479        );
8480    }
8481}
8482
8483#[gpui::test]
8484async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
8485    init_test_with_editor(cx);
8486
8487    let fs = FakeFs::new(cx.executor());
8488    fs.insert_tree(
8489        "/root",
8490        json!({
8491            "file1.txt": "content of file1",
8492            "file2.txt": "content of file2",
8493            "dir1": {
8494                "file3.txt": "content of file3"
8495            }
8496        }),
8497    )
8498    .await;
8499
8500    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8501    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8502    let workspace = window
8503        .read_with(cx, |mw, _| mw.workspace().clone())
8504        .unwrap();
8505    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8506    let panel = workspace.update_in(cx, ProjectPanel::new);
8507    cx.run_until_parked();
8508
8509    let file1_path = "root/file1.txt";
8510    let file2_path = "root/file2.txt";
8511    select_path_with_mark(&panel, file1_path, cx);
8512    select_path_with_mark(&panel, file2_path, cx);
8513
8514    panel.update_in(cx, |panel, window, cx| {
8515        panel.compare_marked_files(&CompareMarkedFiles, window, cx);
8516    });
8517    cx.executor().run_until_parked();
8518
8519    workspace.update_in(cx, |workspace, _, cx| {
8520        let active_items = workspace
8521            .panes()
8522            .iter()
8523            .filter_map(|pane| pane.read(cx).active_item())
8524            .collect::<Vec<_>>();
8525        assert_eq!(active_items.len(), 1);
8526        let diff_view = active_items
8527            .into_iter()
8528            .next()
8529            .unwrap()
8530            .downcast::<FileDiffView>()
8531            .expect("Open item should be an FileDiffView");
8532        assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
8533        assert_eq!(
8534            diff_view.tab_tooltip_text(cx).unwrap(),
8535            format!(
8536                "{}{}",
8537                rel_path(file1_path).display(PathStyle::local()),
8538                rel_path(file2_path).display(PathStyle::local())
8539            )
8540        );
8541    });
8542
8543    let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
8544    let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
8545    let worktree_id = panel.update(cx, |panel, cx| {
8546        panel
8547            .project
8548            .read(cx)
8549            .worktrees(cx)
8550            .next()
8551            .unwrap()
8552            .read(cx)
8553            .id()
8554    });
8555
8556    let expected_entries = [
8557        SelectedEntry {
8558            worktree_id,
8559            entry_id: file1_entry_id,
8560        },
8561        SelectedEntry {
8562            worktree_id,
8563            entry_id: file2_entry_id,
8564        },
8565    ];
8566    panel.update(cx, |panel, _cx| {
8567        assert_eq!(
8568            &panel.marked_entries, &expected_entries,
8569            "Should keep marked entries after comparison"
8570        );
8571    });
8572
8573    panel.update(cx, |panel, cx| {
8574        panel.project.update(cx, |_, cx| {
8575            cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
8576        })
8577    });
8578
8579    panel.update(cx, |panel, _cx| {
8580        assert_eq!(
8581            &panel.marked_entries, &expected_entries,
8582            "Marked entries should persist after focusing back on the project panel"
8583        );
8584    });
8585}
8586
8587#[gpui::test]
8588async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
8589    init_test_with_editor(cx);
8590
8591    let fs = FakeFs::new(cx.executor());
8592    fs.insert_tree(
8593        "/root",
8594        json!({
8595            "file1.txt": "content of file1",
8596            "file2.txt": "content of file2",
8597            "dir1": {},
8598            "dir2": {
8599                "file3.txt": "content of file3"
8600            }
8601        }),
8602    )
8603    .await;
8604
8605    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8606    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8607    let workspace = window
8608        .read_with(cx, |mw, _| mw.workspace().clone())
8609        .unwrap();
8610    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8611    let panel = workspace.update_in(cx, ProjectPanel::new);
8612    cx.run_until_parked();
8613
8614    // Test 1: When only one file is selected, there should be no compare option
8615    select_path(&panel, "root/file1.txt", cx);
8616
8617    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8618    assert_eq!(
8619        selected_files, None,
8620        "Should not have compare option when only one file is selected"
8621    );
8622
8623    // Test 2: When multiple files are selected, there should be a compare option
8624    select_path_with_mark(&panel, "root/file1.txt", cx);
8625    select_path_with_mark(&panel, "root/file2.txt", cx);
8626
8627    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8628    assert!(
8629        selected_files.is_some(),
8630        "Should have files selected for comparison"
8631    );
8632    if let Some((file1, file2)) = selected_files {
8633        assert!(
8634            file1.to_string_lossy().ends_with("file1.txt")
8635                && file2.to_string_lossy().ends_with("file2.txt"),
8636            "Should have file1.txt and file2.txt as the selected files when multi-selecting"
8637        );
8638    }
8639
8640    // Test 3: Selecting a directory shouldn't count as a comparable file
8641    select_path_with_mark(&panel, "root/dir1", cx);
8642
8643    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8644    assert!(
8645        selected_files.is_some(),
8646        "Directory selection should not affect comparable files"
8647    );
8648    if let Some((file1, file2)) = selected_files {
8649        assert!(
8650            file1.to_string_lossy().ends_with("file1.txt")
8651                && file2.to_string_lossy().ends_with("file2.txt"),
8652            "Selecting a directory should not affect the number of comparable files"
8653        );
8654    }
8655
8656    // Test 4: Selecting one more file
8657    select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
8658
8659    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8660    assert!(
8661        selected_files.is_some(),
8662        "Directory selection should not affect comparable files"
8663    );
8664    if let Some((file1, file2)) = selected_files {
8665        assert!(
8666            file1.to_string_lossy().ends_with("file2.txt")
8667                && file2.to_string_lossy().ends_with("file3.txt"),
8668            "Selecting a directory should not affect the number of comparable files"
8669        );
8670    }
8671}
8672
8673#[gpui::test]
8674async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root(
8675    cx: &mut gpui::TestAppContext,
8676) {
8677    init_test(cx);
8678
8679    let fs = FakeFs::new(cx.executor());
8680    fs.insert_tree(
8681        "/root",
8682        json!({
8683            "file.txt": "content",
8684            "dir": {},
8685        }),
8686    )
8687    .await;
8688
8689    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8690    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8691    let workspace = window
8692        .read_with(cx, |mw, _| mw.workspace().clone())
8693        .unwrap();
8694    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8695    let panel = workspace.update_in(cx, ProjectPanel::new);
8696    cx.run_until_parked();
8697
8698    select_path(&panel, "root/file.txt", cx);
8699    let selected_reveal_path = panel
8700        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
8701        .expect("selected entry should produce a reveal path");
8702    assert!(
8703        selected_reveal_path.ends_with(Path::new("file.txt")),
8704        "Expected selected file path, got {:?}",
8705        selected_reveal_path
8706    );
8707
8708    panel.update(cx, |panel, _| {
8709        panel.selection = None;
8710        panel.marked_entries.clear();
8711    });
8712    let fallback_reveal_path = panel
8713        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
8714        .expect("project root should be used when selection is empty");
8715    assert!(
8716        fallback_reveal_path.ends_with(Path::new("root")),
8717        "Expected worktree root path, got {:?}",
8718        fallback_reveal_path
8719    );
8720}
8721
8722#[gpui::test]
8723async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
8724    init_test(cx);
8725
8726    let fs = FakeFs::new(cx.executor());
8727    fs.insert_tree(
8728        "/root",
8729        json!({
8730            ".hidden-file.txt": "hidden file content",
8731            "visible-file.txt": "visible file content",
8732            ".hidden-parent-dir": {
8733                "nested-dir": {
8734                    "file.txt": "file content",
8735                }
8736            },
8737            "visible-dir": {
8738                "file-in-visible.txt": "file content",
8739                "nested": {
8740                    ".hidden-nested-dir": {
8741                        ".double-hidden-dir": {
8742                            "deep-file-1.txt": "deep content 1",
8743                            "deep-file-2.txt": "deep content 2"
8744                        },
8745                        "hidden-nested-file-1.txt": "hidden nested 1",
8746                        "hidden-nested-file-2.txt": "hidden nested 2"
8747                    },
8748                    "visible-nested-file.txt": "visible nested content"
8749                }
8750            }
8751        }),
8752    )
8753    .await;
8754
8755    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8756    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8757    let workspace = window
8758        .read_with(cx, |mw, _| mw.workspace().clone())
8759        .unwrap();
8760    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8761
8762    cx.update(|_, cx| {
8763        let settings = *ProjectPanelSettings::get_global(cx);
8764        ProjectPanelSettings::override_global(
8765            ProjectPanelSettings {
8766                hide_hidden: false,
8767                ..settings
8768            },
8769            cx,
8770        );
8771    });
8772
8773    let panel = workspace.update_in(cx, ProjectPanel::new);
8774    cx.run_until_parked();
8775
8776    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
8777    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
8778    toggle_expand_dir(&panel, "root/visible-dir", cx);
8779    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
8780    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
8781    toggle_expand_dir(
8782        &panel,
8783        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
8784        cx,
8785    );
8786
8787    let expanded = [
8788        "v root",
8789        "    v .hidden-parent-dir",
8790        "        v nested-dir",
8791        "              file.txt",
8792        "    v visible-dir",
8793        "        v nested",
8794        "            v .hidden-nested-dir",
8795        "                v .double-hidden-dir  <== selected",
8796        "                      deep-file-1.txt",
8797        "                      deep-file-2.txt",
8798        "                  hidden-nested-file-1.txt",
8799        "                  hidden-nested-file-2.txt",
8800        "              visible-nested-file.txt",
8801        "          file-in-visible.txt",
8802        "      .hidden-file.txt",
8803        "      visible-file.txt",
8804    ];
8805
8806    assert_eq!(
8807        visible_entries_as_strings(&panel, 0..30, cx),
8808        &expanded,
8809        "With hide_hidden=false, contents of hidden nested directory should be visible"
8810    );
8811
8812    cx.update(|_, cx| {
8813        let settings = *ProjectPanelSettings::get_global(cx);
8814        ProjectPanelSettings::override_global(
8815            ProjectPanelSettings {
8816                hide_hidden: true,
8817                ..settings
8818            },
8819            cx,
8820        );
8821    });
8822
8823    panel.update_in(cx, |panel, window, cx| {
8824        panel.update_visible_entries(None, false, false, window, cx);
8825    });
8826    cx.run_until_parked();
8827
8828    assert_eq!(
8829        visible_entries_as_strings(&panel, 0..30, cx),
8830        &[
8831            "v root",
8832            "    v visible-dir",
8833            "        v nested",
8834            "              visible-nested-file.txt",
8835            "          file-in-visible.txt",
8836            "      visible-file.txt",
8837        ],
8838        "With hide_hidden=false, contents of hidden nested directory should be visible"
8839    );
8840
8841    panel.update_in(cx, |panel, window, cx| {
8842        let settings = *ProjectPanelSettings::get_global(cx);
8843        ProjectPanelSettings::override_global(
8844            ProjectPanelSettings {
8845                hide_hidden: false,
8846                ..settings
8847            },
8848            cx,
8849        );
8850        panel.update_visible_entries(None, false, false, window, cx);
8851    });
8852    cx.run_until_parked();
8853
8854    assert_eq!(
8855        visible_entries_as_strings(&panel, 0..30, cx),
8856        &expanded,
8857        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
8858    );
8859}
8860
8861fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8862    let path = rel_path(path);
8863    panel.update_in(cx, |panel, window, cx| {
8864        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8865            let worktree = worktree.read(cx);
8866            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8867                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8868                panel.update_visible_entries(
8869                    Some((worktree.id(), entry_id)),
8870                    false,
8871                    false,
8872                    window,
8873                    cx,
8874                );
8875                return;
8876            }
8877        }
8878        panic!("no worktree for path {:?}", path);
8879    });
8880    cx.run_until_parked();
8881}
8882
8883fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8884    let path = rel_path(path);
8885    panel.update(cx, |panel, cx| {
8886        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8887            let worktree = worktree.read(cx);
8888            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8889                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8890                let entry = crate::SelectedEntry {
8891                    worktree_id: worktree.id(),
8892                    entry_id,
8893                };
8894                if !panel.marked_entries.contains(&entry) {
8895                    panel.marked_entries.push(entry);
8896                }
8897                panel.selection = Some(entry);
8898                return;
8899            }
8900        }
8901        panic!("no worktree for path {:?}", path);
8902    });
8903}
8904
8905/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
8906/// `active_ancestor_path` is the path to the folded component that should be active.
8907fn select_folded_path_with_mark(
8908    panel: &Entity<ProjectPanel>,
8909    leaf_path: &str,
8910    active_ancestor_path: &str,
8911    cx: &mut VisualTestContext,
8912) {
8913    select_path_with_mark(panel, leaf_path, cx);
8914    set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
8915}
8916
8917fn set_folded_active_ancestor(
8918    panel: &Entity<ProjectPanel>,
8919    leaf_path: &str,
8920    active_ancestor_path: &str,
8921    cx: &mut VisualTestContext,
8922) {
8923    let leaf_path = rel_path(leaf_path);
8924    let active_ancestor_path = rel_path(active_ancestor_path);
8925    panel.update(cx, |panel, cx| {
8926        let mut leaf_entry_id = None;
8927        let mut target_entry_id = None;
8928
8929        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8930            let worktree = worktree.read(cx);
8931            if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
8932                leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8933            }
8934            if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
8935                target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8936            }
8937        }
8938
8939        let leaf_entry_id =
8940            leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
8941        let target_entry_id = target_entry_id
8942            .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
8943        let folded_ancestors = panel
8944            .state
8945            .ancestors
8946            .get_mut(&leaf_entry_id)
8947            .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
8948        let ancestor_ids = folded_ancestors.ancestors.clone();
8949
8950        let mut depth_for_target = None;
8951        for depth in 0..ancestor_ids.len() {
8952            let resolved_entry_id = if depth == 0 {
8953                leaf_entry_id
8954            } else {
8955                ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
8956            };
8957            if resolved_entry_id == target_entry_id {
8958                depth_for_target = Some(depth);
8959                break;
8960            }
8961        }
8962
8963        folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
8964            panic!(
8965                "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
8966            )
8967        });
8968    });
8969}
8970
8971fn drag_selection_to(
8972    panel: &Entity<ProjectPanel>,
8973    target_path: &str,
8974    is_file: bool,
8975    cx: &mut VisualTestContext,
8976) {
8977    let target_entry = find_project_entry(panel, target_path, cx)
8978        .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
8979
8980    panel.update_in(cx, |panel, window, cx| {
8981        let selection = panel
8982            .selection
8983            .expect("a selection is required before dragging");
8984        let drag = DraggedSelection {
8985            active_selection: SelectedEntry {
8986                worktree_id: selection.worktree_id,
8987                entry_id: panel.resolve_entry(selection.entry_id),
8988            },
8989            marked_selections: Arc::from(panel.marked_entries.clone()),
8990        };
8991        panel.drag_onto(&drag, target_entry, is_file, window, cx);
8992    });
8993    cx.executor().run_until_parked();
8994}
8995
8996fn find_project_entry(
8997    panel: &Entity<ProjectPanel>,
8998    path: &str,
8999    cx: &mut VisualTestContext,
9000) -> Option<ProjectEntryId> {
9001    let path = rel_path(path);
9002    panel.update(cx, |panel, cx| {
9003        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9004            let worktree = worktree.read(cx);
9005            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9006                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9007            }
9008        }
9009        panic!("no worktree for path {path:?}");
9010    })
9011}
9012
9013fn visible_entries_as_strings(
9014    panel: &Entity<ProjectPanel>,
9015    range: Range<usize>,
9016    cx: &mut VisualTestContext,
9017) -> Vec<String> {
9018    let mut result = Vec::new();
9019    let mut project_entries = HashSet::default();
9020    let mut has_editor = false;
9021
9022    panel.update_in(cx, |panel, window, cx| {
9023        panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
9024            if details.is_editing {
9025                assert!(!has_editor, "duplicate editor entry");
9026                has_editor = true;
9027            } else {
9028                assert!(
9029                    project_entries.insert(project_entry),
9030                    "duplicate project entry {:?} {:?}",
9031                    project_entry,
9032                    details
9033                );
9034            }
9035
9036            let indent = "    ".repeat(details.depth);
9037            let icon = if details.kind.is_dir() {
9038                if details.is_expanded { "v " } else { "> " }
9039            } else {
9040                "  "
9041            };
9042            #[cfg(windows)]
9043            let filename = details.filename.replace("\\", "/");
9044            #[cfg(not(windows))]
9045            let filename = details.filename;
9046            let name = if details.is_editing {
9047                format!("[EDITOR: '{}']", filename)
9048            } else if details.is_processing {
9049                format!("[PROCESSING: '{}']", filename)
9050            } else {
9051                filename
9052            };
9053            let selected = if details.is_selected {
9054                "  <== selected"
9055            } else {
9056                ""
9057            };
9058            let marked = if details.is_marked {
9059                "  <== marked"
9060            } else {
9061                ""
9062            };
9063
9064            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9065        });
9066    });
9067
9068    result
9069}
9070
9071/// Test that missing sort_mode field defaults to DirectoriesFirst
9072#[gpui::test]
9073async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
9074    init_test(cx);
9075
9076    // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
9077    let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
9078    assert_eq!(
9079        default_settings.sort_mode,
9080        settings::ProjectPanelSortMode::DirectoriesFirst,
9081        "sort_mode should default to DirectoriesFirst"
9082    );
9083}
9084
9085/// Test sort modes: DirectoriesFirst (default) vs Mixed
9086#[gpui::test]
9087async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
9088    init_test(cx);
9089
9090    let fs = FakeFs::new(cx.executor());
9091    fs.insert_tree(
9092        "/root",
9093        json!({
9094            "zebra.txt": "",
9095            "Apple": {},
9096            "banana.rs": "",
9097            "Carrot": {},
9098            "aardvark.txt": "",
9099        }),
9100    )
9101    .await;
9102
9103    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9104    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9105    let workspace = window
9106        .read_with(cx, |mw, _| mw.workspace().clone())
9107        .unwrap();
9108    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9109    let panel = workspace.update_in(cx, ProjectPanel::new);
9110    cx.run_until_parked();
9111
9112    // Default sort mode should be DirectoriesFirst
9113    assert_eq!(
9114        visible_entries_as_strings(&panel, 0..50, cx),
9115        &[
9116            "v root",
9117            "    > Apple",
9118            "    > Carrot",
9119            "      aardvark.txt",
9120            "      banana.rs",
9121            "      zebra.txt",
9122        ]
9123    );
9124}
9125
9126#[gpui::test]
9127async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
9128    init_test(cx);
9129
9130    let fs = FakeFs::new(cx.executor());
9131    fs.insert_tree(
9132        "/root",
9133        json!({
9134            "Zebra.txt": "",
9135            "apple": {},
9136            "Banana.rs": "",
9137            "carrot": {},
9138            "Aardvark.txt": "",
9139        }),
9140    )
9141    .await;
9142
9143    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9144    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9145    let workspace = window
9146        .read_with(cx, |mw, _| mw.workspace().clone())
9147        .unwrap();
9148    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9149
9150    // Switch to Mixed mode
9151    cx.update(|_, cx| {
9152        cx.update_global::<SettingsStore, _>(|store, cx| {
9153            store.update_user_settings(cx, |settings| {
9154                settings.project_panel.get_or_insert_default().sort_mode =
9155                    Some(settings::ProjectPanelSortMode::Mixed);
9156            });
9157        });
9158    });
9159
9160    let panel = workspace.update_in(cx, ProjectPanel::new);
9161    cx.run_until_parked();
9162
9163    // Mixed mode: case-insensitive sorting
9164    // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
9165    assert_eq!(
9166        visible_entries_as_strings(&panel, 0..50, cx),
9167        &[
9168            "v root",
9169            "      Aardvark.txt",
9170            "    > apple",
9171            "      Banana.rs",
9172            "    > carrot",
9173            "      Zebra.txt",
9174        ]
9175    );
9176}
9177
9178#[gpui::test]
9179async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
9180    init_test(cx);
9181
9182    let fs = FakeFs::new(cx.executor());
9183    fs.insert_tree(
9184        "/root",
9185        json!({
9186            "Zebra.txt": "",
9187            "apple": {},
9188            "Banana.rs": "",
9189            "carrot": {},
9190            "Aardvark.txt": "",
9191        }),
9192    )
9193    .await;
9194
9195    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9196    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9197    let workspace = window
9198        .read_with(cx, |mw, _| mw.workspace().clone())
9199        .unwrap();
9200    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9201
9202    // Switch to FilesFirst mode
9203    cx.update(|_, cx| {
9204        cx.update_global::<SettingsStore, _>(|store, cx| {
9205            store.update_user_settings(cx, |settings| {
9206                settings.project_panel.get_or_insert_default().sort_mode =
9207                    Some(settings::ProjectPanelSortMode::FilesFirst);
9208            });
9209        });
9210    });
9211
9212    let panel = workspace.update_in(cx, ProjectPanel::new);
9213    cx.run_until_parked();
9214
9215    // FilesFirst mode: files first, then directories (both case-insensitive)
9216    assert_eq!(
9217        visible_entries_as_strings(&panel, 0..50, cx),
9218        &[
9219            "v root",
9220            "      Aardvark.txt",
9221            "      Banana.rs",
9222            "      Zebra.txt",
9223            "    > apple",
9224            "    > carrot",
9225        ]
9226    );
9227}
9228
9229#[gpui::test]
9230async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
9231    init_test(cx);
9232
9233    let fs = FakeFs::new(cx.executor());
9234    fs.insert_tree(
9235        "/root",
9236        json!({
9237            "file2.txt": "",
9238            "dir1": {},
9239            "file1.txt": "",
9240        }),
9241    )
9242    .await;
9243
9244    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9245    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9246    let workspace = window
9247        .read_with(cx, |mw, _| mw.workspace().clone())
9248        .unwrap();
9249    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9250    let panel = workspace.update_in(cx, ProjectPanel::new);
9251    cx.run_until_parked();
9252
9253    // Initially DirectoriesFirst
9254    assert_eq!(
9255        visible_entries_as_strings(&panel, 0..50, cx),
9256        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9257    );
9258
9259    // Toggle to Mixed
9260    cx.update(|_, cx| {
9261        cx.update_global::<SettingsStore, _>(|store, cx| {
9262            store.update_user_settings(cx, |settings| {
9263                settings.project_panel.get_or_insert_default().sort_mode =
9264                    Some(settings::ProjectPanelSortMode::Mixed);
9265            });
9266        });
9267    });
9268    cx.run_until_parked();
9269
9270    assert_eq!(
9271        visible_entries_as_strings(&panel, 0..50, cx),
9272        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9273    );
9274
9275    // Toggle back to DirectoriesFirst
9276    cx.update(|_, cx| {
9277        cx.update_global::<SettingsStore, _>(|store, cx| {
9278            store.update_user_settings(cx, |settings| {
9279                settings.project_panel.get_or_insert_default().sort_mode =
9280                    Some(settings::ProjectPanelSortMode::DirectoriesFirst);
9281            });
9282        });
9283    });
9284    cx.run_until_parked();
9285
9286    assert_eq!(
9287        visible_entries_as_strings(&panel, 0..50, cx),
9288        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
9289    );
9290}
9291
9292#[gpui::test]
9293async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
9294    cx: &mut gpui::TestAppContext,
9295) {
9296    init_test(cx);
9297
9298    // parent: accept
9299    run_create_file_in_folded_path_case(
9300        "parent",
9301        "root1/parent",
9302        "file_in_parent.txt",
9303        &[
9304            "v root1",
9305            "    v parent",
9306            "        > subdir/child",
9307            "          [EDITOR: '']  <== selected",
9308        ],
9309        &[
9310            "v root1",
9311            "    v parent",
9312            "        > subdir/child",
9313            "          file_in_parent.txt  <== selected  <== marked",
9314        ],
9315        true,
9316        cx,
9317    )
9318    .await;
9319
9320    // parent: cancel
9321    run_create_file_in_folded_path_case(
9322        "parent",
9323        "root1/parent",
9324        "file_in_parent.txt",
9325        &[
9326            "v root1",
9327            "    v parent",
9328            "        > subdir/child",
9329            "          [EDITOR: '']  <== selected",
9330        ],
9331        &["v root1", "    > parent/subdir/child  <== selected"],
9332        false,
9333        cx,
9334    )
9335    .await;
9336
9337    // subdir: accept
9338    run_create_file_in_folded_path_case(
9339        "subdir",
9340        "root1/parent/subdir",
9341        "file_in_subdir.txt",
9342        &[
9343            "v root1",
9344            "    v parent/subdir",
9345            "        > child",
9346            "          [EDITOR: '']  <== selected",
9347        ],
9348        &[
9349            "v root1",
9350            "    v parent/subdir",
9351            "        > child",
9352            "          file_in_subdir.txt  <== selected  <== marked",
9353        ],
9354        true,
9355        cx,
9356    )
9357    .await;
9358
9359    // subdir: cancel
9360    run_create_file_in_folded_path_case(
9361        "subdir",
9362        "root1/parent/subdir",
9363        "file_in_subdir.txt",
9364        &[
9365            "v root1",
9366            "    v parent/subdir",
9367            "        > child",
9368            "          [EDITOR: '']  <== selected",
9369        ],
9370        &["v root1", "    > parent/subdir/child  <== selected"],
9371        false,
9372        cx,
9373    )
9374    .await;
9375
9376    // child: accept
9377    run_create_file_in_folded_path_case(
9378        "child",
9379        "root1/parent/subdir/child",
9380        "file_in_child.txt",
9381        &[
9382            "v root1",
9383            "    v parent/subdir/child",
9384            "          [EDITOR: '']  <== selected",
9385        ],
9386        &[
9387            "v root1",
9388            "    v parent/subdir/child",
9389            "          file_in_child.txt  <== selected  <== marked",
9390        ],
9391        true,
9392        cx,
9393    )
9394    .await;
9395
9396    // child: cancel
9397    run_create_file_in_folded_path_case(
9398        "child",
9399        "root1/parent/subdir/child",
9400        "file_in_child.txt",
9401        &[
9402            "v root1",
9403            "    v parent/subdir/child",
9404            "          [EDITOR: '']  <== selected",
9405        ],
9406        &["v root1", "    v parent/subdir/child  <== selected"],
9407        false,
9408        cx,
9409    )
9410    .await;
9411}
9412
9413#[gpui::test]
9414async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
9415    cx: &mut gpui::TestAppContext,
9416) {
9417    init_test(cx);
9418
9419    let fs = FakeFs::new(cx.executor());
9420    fs.insert_tree(
9421        "/root1",
9422        json!({
9423            "parent": {
9424                "subdir": {
9425                    "child": {},
9426                }
9427            }
9428        }),
9429    )
9430    .await;
9431
9432    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9433    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9434    let workspace = window
9435        .read_with(cx, |mw, _| mw.workspace().clone())
9436        .unwrap();
9437    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9438
9439    let panel = workspace.update_in(cx, |workspace, window, cx| {
9440        let panel = ProjectPanel::new(workspace, window, cx);
9441        workspace.add_panel(panel.clone(), window, cx);
9442        panel
9443    });
9444
9445    cx.update(|_, cx| {
9446        let settings = *ProjectPanelSettings::get_global(cx);
9447        ProjectPanelSettings::override_global(
9448            ProjectPanelSettings {
9449                auto_fold_dirs: true,
9450                ..settings
9451            },
9452            cx,
9453        );
9454    });
9455
9456    panel.update_in(cx, |panel, window, cx| {
9457        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9458    });
9459    cx.run_until_parked();
9460
9461    select_folded_path_with_mark(
9462        &panel,
9463        "root1/parent/subdir/child",
9464        "root1/parent/subdir",
9465        cx,
9466    );
9467    panel.update(cx, |panel, _| {
9468        panel.marked_entries.clear();
9469    });
9470
9471    let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
9472        .expect("parent directory should exist for this test");
9473    let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
9474        .expect("subdir directory should exist for this test");
9475    let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
9476        .expect("child directory should exist for this test");
9477
9478    panel.update(cx, |panel, _| {
9479        let selection = panel
9480            .selection
9481            .expect("leaf directory should be selected before creating a new entry");
9482        assert_eq!(
9483            selection.entry_id, child_entry_id,
9484            "initial selection should be the folded leaf entry"
9485        );
9486        assert_eq!(
9487            panel.resolve_entry(selection.entry_id),
9488            subdir_entry_id,
9489            "active folded component should start at subdir"
9490        );
9491    });
9492
9493    panel.update_in(cx, |panel, window, cx| {
9494        panel.deploy_context_menu(
9495            gpui::point(gpui::px(1.), gpui::px(1.)),
9496            child_entry_id,
9497            window,
9498            cx,
9499        );
9500        panel.new_file(&NewFile, window, cx);
9501    });
9502    cx.run_until_parked();
9503    panel.update_in(cx, |panel, window, cx| {
9504        assert!(panel.filename_editor.read(cx).is_focused(window));
9505    });
9506    cx.run_until_parked();
9507
9508    set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
9509
9510    panel.update_in(cx, |panel, window, cx| {
9511        panel.deploy_context_menu(
9512            gpui::point(gpui::px(2.), gpui::px(2.)),
9513            subdir_entry_id,
9514            window,
9515            cx,
9516        );
9517    });
9518    cx.run_until_parked();
9519
9520    panel.update(cx, |panel, _| {
9521        assert!(
9522            panel.state.edit_state.is_none(),
9523            "opening another context menu should blur the filename editor and discard edit state"
9524        );
9525        let selection = panel
9526            .selection
9527            .expect("selection should restore to the previously focused leaf entry");
9528        assert_eq!(
9529            selection.entry_id, child_entry_id,
9530            "blur-driven cancellation should restore the previous leaf selection"
9531        );
9532        assert_eq!(
9533            panel.resolve_entry(selection.entry_id),
9534            parent_entry_id,
9535            "temporary unfolded pending state should preserve the active ancestor chosen before blur"
9536        );
9537    });
9538
9539    panel.update_in(cx, |panel, window, cx| {
9540        panel.new_file(&NewFile, window, cx);
9541    });
9542    cx.run_until_parked();
9543    assert_eq!(
9544        visible_entries_as_strings(&panel, 0..10, cx),
9545        &[
9546            "v root1",
9547            "    v parent",
9548            "        > subdir/child",
9549            "          [EDITOR: '']  <== selected",
9550        ],
9551        "new file after blur should use the preserved active ancestor"
9552    );
9553    panel.update(cx, |panel, _| {
9554        let edit_state = panel
9555            .state
9556            .edit_state
9557            .as_ref()
9558            .expect("new file should enter edit state");
9559        assert_eq!(
9560            edit_state.temporarily_unfolded,
9561            Some(parent_entry_id),
9562            "temporary unfolding should now target parent after restoring the active ancestor"
9563        );
9564    });
9565
9566    let file_name = "created_after_blur.txt";
9567    panel
9568        .update_in(cx, |panel, window, cx| {
9569            panel.filename_editor.update(cx, |editor, cx| {
9570                editor.set_text(file_name, window, cx);
9571            });
9572            panel.confirm_edit(true, window, cx).expect(
9573                "confirm_edit should start creation for the file created after blur transition",
9574            )
9575        })
9576        .await
9577        .expect("creating file after blur transition should succeed");
9578    cx.run_until_parked();
9579
9580    assert!(
9581        fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
9582            .await,
9583        "file should be created under parent after active ancestor is restored to parent"
9584    );
9585    assert!(
9586        !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
9587            .await,
9588        "file should not be created under subdir when parent is the active ancestor"
9589    );
9590}
9591
9592async fn run_create_file_in_folded_path_case(
9593    case_name: &str,
9594    active_ancestor_path: &str,
9595    created_file_name: &str,
9596    expected_temporary_state: &[&str],
9597    expected_final_state: &[&str],
9598    accept_creation: bool,
9599    cx: &mut gpui::TestAppContext,
9600) {
9601    let expected_collapsed_state = &["v root1", "    > parent/subdir/child  <== selected"];
9602
9603    let fs = FakeFs::new(cx.executor());
9604    fs.insert_tree(
9605        "/root1",
9606        json!({
9607            "parent": {
9608                "subdir": {
9609                    "child": {},
9610                }
9611            }
9612        }),
9613    )
9614    .await;
9615
9616    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9617    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9618    let workspace = window
9619        .read_with(cx, |mw, _| mw.workspace().clone())
9620        .unwrap();
9621    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9622
9623    let panel = workspace.update_in(cx, |workspace, window, cx| {
9624        let panel = ProjectPanel::new(workspace, window, cx);
9625        workspace.add_panel(panel.clone(), window, cx);
9626        panel
9627    });
9628
9629    cx.update(|_, cx| {
9630        let settings = *ProjectPanelSettings::get_global(cx);
9631        ProjectPanelSettings::override_global(
9632            ProjectPanelSettings {
9633                auto_fold_dirs: true,
9634                ..settings
9635            },
9636            cx,
9637        );
9638    });
9639
9640    panel.update_in(cx, |panel, window, cx| {
9641        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9642    });
9643    cx.run_until_parked();
9644
9645    select_folded_path_with_mark(
9646        &panel,
9647        "root1/parent/subdir/child",
9648        active_ancestor_path,
9649        cx,
9650    );
9651    panel.update(cx, |panel, _| {
9652        panel.marked_entries.clear();
9653    });
9654
9655    assert_eq!(
9656        visible_entries_as_strings(&panel, 0..10, cx),
9657        expected_collapsed_state,
9658        "case '{}' should start from a folded state",
9659        case_name
9660    );
9661
9662    panel.update_in(cx, |panel, window, cx| {
9663        panel.new_file(&NewFile, window, cx);
9664    });
9665    cx.run_until_parked();
9666    panel.update_in(cx, |panel, window, cx| {
9667        assert!(panel.filename_editor.read(cx).is_focused(window));
9668    });
9669    cx.run_until_parked();
9670    assert_eq!(
9671        visible_entries_as_strings(&panel, 0..10, cx),
9672        expected_temporary_state,
9673        "case '{}' ({}) should temporarily unfold the active ancestor while editing",
9674        case_name,
9675        if accept_creation { "accept" } else { "cancel" }
9676    );
9677
9678    let relative_directory = active_ancestor_path
9679        .strip_prefix("root1/")
9680        .expect("active_ancestor_path should start with root1/");
9681    let created_file_path = PathBuf::from("/root1")
9682        .join(relative_directory)
9683        .join(created_file_name);
9684
9685    if accept_creation {
9686        panel
9687            .update_in(cx, |panel, window, cx| {
9688                panel.filename_editor.update(cx, |editor, cx| {
9689                    editor.set_text(created_file_name, window, cx);
9690                });
9691                panel.confirm_edit(true, window, cx).unwrap()
9692            })
9693            .await
9694            .unwrap();
9695        cx.run_until_parked();
9696
9697        assert_eq!(
9698            visible_entries_as_strings(&panel, 0..10, cx),
9699            expected_final_state,
9700            "case '{}' should keep the newly created file selected and marked after accept",
9701            case_name
9702        );
9703        assert!(
9704            fs.is_file(created_file_path.as_path()).await,
9705            "case '{}' should create file '{}'",
9706            case_name,
9707            created_file_path.display()
9708        );
9709    } else {
9710        panel.update_in(cx, |panel, window, cx| {
9711            panel.cancel(&Cancel, window, cx);
9712        });
9713        cx.run_until_parked();
9714
9715        assert_eq!(
9716            visible_entries_as_strings(&panel, 0..10, cx),
9717            expected_final_state,
9718            "case '{}' should keep the expected panel state after cancel",
9719            case_name
9720        );
9721        assert!(
9722            !fs.is_file(created_file_path.as_path()).await,
9723            "case '{}' should not create a file after cancel",
9724            case_name
9725        );
9726    }
9727}
9728
9729fn init_test(cx: &mut TestAppContext) {
9730    cx.update(|cx| {
9731        let settings_store = SettingsStore::test(cx);
9732        cx.set_global(settings_store);
9733        theme::init(theme::LoadThemes::JustBase, cx);
9734        crate::init(cx);
9735
9736        cx.update_global::<SettingsStore, _>(|store, cx| {
9737            store.update_user_settings(cx, |settings| {
9738                settings
9739                    .project_panel
9740                    .get_or_insert_default()
9741                    .auto_fold_dirs = Some(false);
9742                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
9743            });
9744        });
9745    });
9746}
9747
9748fn init_test_with_editor(cx: &mut TestAppContext) {
9749    cx.update(|cx| {
9750        let app_state = AppState::test(cx);
9751        theme::init(theme::LoadThemes::JustBase, cx);
9752        editor::init(cx);
9753        crate::init(cx);
9754        workspace::init(app_state, cx);
9755
9756        cx.update_global::<SettingsStore, _>(|store, cx| {
9757            store.update_user_settings(cx, |settings| {
9758                settings
9759                    .project_panel
9760                    .get_or_insert_default()
9761                    .auto_fold_dirs = Some(false);
9762                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
9763            });
9764        });
9765    });
9766}
9767
9768fn set_auto_open_settings(
9769    cx: &mut TestAppContext,
9770    auto_open_settings: ProjectPanelAutoOpenSettings,
9771) {
9772    cx.update(|cx| {
9773        cx.update_global::<SettingsStore, _>(|store, cx| {
9774            store.update_user_settings(cx, |settings| {
9775                settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
9776            });
9777        })
9778    });
9779}
9780
9781fn ensure_single_file_is_opened(
9782    workspace: &Entity<Workspace>,
9783    expected_path: &str,
9784    cx: &mut VisualTestContext,
9785) {
9786    workspace.update_in(cx, |workspace, _, cx| {
9787        let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9788        assert_eq!(worktrees.len(), 1);
9789        let worktree_id = worktrees[0].read(cx).id();
9790
9791        let open_project_paths = workspace
9792            .panes()
9793            .iter()
9794            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9795            .collect::<Vec<_>>();
9796        assert_eq!(
9797            open_project_paths,
9798            vec![ProjectPath {
9799                worktree_id,
9800                path: Arc::from(rel_path(expected_path))
9801            }],
9802            "Should have opened file, selected in project panel"
9803        );
9804    });
9805}
9806
9807fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9808    assert!(
9809        !cx.has_pending_prompt(),
9810        "Should have no prompts before the deletion"
9811    );
9812    panel.update_in(cx, |panel, window, cx| {
9813        panel.delete(&Delete { skip_prompt: false }, window, cx)
9814    });
9815    assert!(
9816        cx.has_pending_prompt(),
9817        "Should have a prompt after the deletion"
9818    );
9819    cx.simulate_prompt_answer("Delete");
9820    assert!(
9821        !cx.has_pending_prompt(),
9822        "Should have no prompts after prompt was replied to"
9823    );
9824    cx.executor().run_until_parked();
9825}
9826
9827fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9828    assert!(
9829        !cx.has_pending_prompt(),
9830        "Should have no prompts before the deletion"
9831    );
9832    panel.update_in(cx, |panel, window, cx| {
9833        panel.delete(&Delete { skip_prompt: true }, window, cx)
9834    });
9835    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9836    cx.executor().run_until_parked();
9837}
9838
9839fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
9840    assert!(
9841        !cx.has_pending_prompt(),
9842        "Should have no prompts after deletion operation closes the file"
9843    );
9844    workspace.update_in(cx, |workspace, _window, cx| {
9845        let open_project_paths = workspace
9846            .panes()
9847            .iter()
9848            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9849            .collect::<Vec<_>>();
9850        assert!(
9851            open_project_paths.is_empty(),
9852            "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9853        );
9854    });
9855}
9856
9857struct TestProjectItemView {
9858    focus_handle: FocusHandle,
9859    path: ProjectPath,
9860}
9861
9862struct TestProjectItem {
9863    path: ProjectPath,
9864}
9865
9866impl project::ProjectItem for TestProjectItem {
9867    fn try_open(
9868        _project: &Entity<Project>,
9869        path: &ProjectPath,
9870        cx: &mut App,
9871    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
9872        let path = path.clone();
9873        Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
9874    }
9875
9876    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9877        None
9878    }
9879
9880    fn project_path(&self, _: &App) -> Option<ProjectPath> {
9881        Some(self.path.clone())
9882    }
9883
9884    fn is_dirty(&self) -> bool {
9885        false
9886    }
9887}
9888
9889impl ProjectItem for TestProjectItemView {
9890    type Item = TestProjectItem;
9891
9892    fn for_project_item(
9893        _: Entity<Project>,
9894        _: Option<&Pane>,
9895        project_item: Entity<Self::Item>,
9896        _: &mut Window,
9897        cx: &mut Context<Self>,
9898    ) -> Self
9899    where
9900        Self: Sized,
9901    {
9902        Self {
9903            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9904            focus_handle: cx.focus_handle(),
9905        }
9906    }
9907}
9908
9909impl Item for TestProjectItemView {
9910    type Event = ();
9911
9912    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
9913        "Test".into()
9914    }
9915}
9916
9917impl EventEmitter<()> for TestProjectItemView {}
9918
9919impl Focusable for TestProjectItemView {
9920    fn focus_handle(&self, _: &App) -> FocusHandle {
9921        self.focus_handle.clone()
9922    }
9923}
9924
9925impl Render for TestProjectItemView {
9926    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9927        Empty
9928    }
9929}