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