project_panel_tests.rs

   1use super::*;
   2use collections::HashSet;
   3use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
   4use pretty_assertions::assert_eq;
   5use project::FakeFs;
   6use serde_json::json;
   7use settings::SettingsStore;
   8use std::path::{Path, PathBuf};
   9use util::{path, paths::PathStyle, rel_path::rel_path};
  10use workspace::{
  11    AppState, ItemHandle, Pane,
  12    item::{Item, ProjectItem},
  13    register_project_item,
  14};
  15
  16#[gpui::test]
  17async fn test_visible_list(cx: &mut gpui::TestAppContext) {
  18    init_test(cx);
  19
  20    let fs = FakeFs::new(cx.executor());
  21    fs.insert_tree(
  22        "/root1",
  23        json!({
  24            ".dockerignore": "",
  25            ".git": {
  26                "HEAD": "",
  27            },
  28            "a": {
  29                "0": { "q": "", "r": "", "s": "" },
  30                "1": { "t": "", "u": "" },
  31                "2": { "v": "", "w": "", "x": "", "y": "" },
  32            },
  33            "b": {
  34                "3": { "Q": "" },
  35                "4": { "R": "", "S": "", "T": "", "U": "" },
  36            },
  37            "C": {
  38                "5": {},
  39                "6": { "V": "", "W": "" },
  40                "7": { "X": "" },
  41                "8": { "Y": {}, "Z": "" }
  42            }
  43        }),
  44    )
  45    .await;
  46    fs.insert_tree(
  47        "/root2",
  48        json!({
  49            "d": {
  50                "9": ""
  51            },
  52            "e": {}
  53        }),
  54    )
  55    .await;
  56
  57    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
  58    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
  59    let cx = &mut VisualTestContext::from_window(*workspace, cx);
  60    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
  61    cx.run_until_parked();
  62    assert_eq!(
  63        visible_entries_as_strings(&panel, 0..50, cx),
  64        &[
  65            "v root1",
  66            "    > .git",
  67            "    > a",
  68            "    > b",
  69            "    > C",
  70            "      .dockerignore",
  71            "v root2",
  72            "    > d",
  73            "    > e",
  74        ]
  75    );
  76
  77    toggle_expand_dir(&panel, "root1/b", cx);
  78    assert_eq!(
  79        visible_entries_as_strings(&panel, 0..50, cx),
  80        &[
  81            "v root1",
  82            "    > .git",
  83            "    > a",
  84            "    v b  <== selected",
  85            "        > 3",
  86            "        > 4",
  87            "    > C",
  88            "      .dockerignore",
  89            "v root2",
  90            "    > d",
  91            "    > e",
  92        ]
  93    );
  94
  95    assert_eq!(
  96        visible_entries_as_strings(&panel, 6..9, cx),
  97        &[
  98            //
  99            "    > C",
 100            "      .dockerignore",
 101            "v root2",
 102        ]
 103    );
 104}
 105
 106#[gpui::test]
 107async fn test_opening_file(cx: &mut gpui::TestAppContext) {
 108    init_test_with_editor(cx);
 109
 110    let fs = FakeFs::new(cx.executor());
 111    fs.insert_tree(
 112        path!("/src"),
 113        json!({
 114            "test": {
 115                "first.rs": "// First Rust file",
 116                "second.rs": "// Second Rust file",
 117                "third.rs": "// Third Rust file",
 118            }
 119        }),
 120    )
 121    .await;
 122
 123    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
 124    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 125    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 126    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 127    cx.run_until_parked();
 128
 129    toggle_expand_dir(&panel, "src/test", cx);
 130    select_path(&panel, "src/test/first.rs", cx);
 131    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 132    cx.executor().run_until_parked();
 133    assert_eq!(
 134        visible_entries_as_strings(&panel, 0..10, cx),
 135        &[
 136            "v src",
 137            "    v test",
 138            "          first.rs  <== selected  <== marked",
 139            "          second.rs",
 140            "          third.rs"
 141        ]
 142    );
 143    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
 144
 145    select_path(&panel, "src/test/second.rs", cx);
 146    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 147    cx.executor().run_until_parked();
 148    assert_eq!(
 149        visible_entries_as_strings(&panel, 0..10, cx),
 150        &[
 151            "v src",
 152            "    v test",
 153            "          first.rs",
 154            "          second.rs  <== selected  <== marked",
 155            "          third.rs"
 156        ]
 157    );
 158    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
 159}
 160
 161#[gpui::test]
 162async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
 163    init_test(cx);
 164    cx.update(|cx| {
 165        cx.update_global::<SettingsStore, _>(|store, cx| {
 166            store.update_user_settings(cx, |settings| {
 167                settings.project.worktree.file_scan_exclusions =
 168                    Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
 169            });
 170        });
 171    });
 172
 173    let fs = FakeFs::new(cx.background_executor.clone());
 174    fs.insert_tree(
 175        "/root1",
 176        json!({
 177            ".dockerignore": "",
 178            ".git": {
 179                "HEAD": "",
 180            },
 181            "a": {
 182                "0": { "q": "", "r": "", "s": "" },
 183                "1": { "t": "", "u": "" },
 184                "2": { "v": "", "w": "", "x": "", "y": "" },
 185            },
 186            "b": {
 187                "3": { "Q": "" },
 188                "4": { "R": "", "S": "", "T": "", "U": "" },
 189            },
 190            "C": {
 191                "5": {},
 192                "6": { "V": "", "W": "" },
 193                "7": { "X": "" },
 194                "8": { "Y": {}, "Z": "" }
 195            }
 196        }),
 197    )
 198    .await;
 199    fs.insert_tree(
 200        "/root2",
 201        json!({
 202            "d": {
 203                "4": ""
 204            },
 205            "e": {}
 206        }),
 207    )
 208    .await;
 209
 210    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 211    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 212    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 213    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 214    cx.run_until_parked();
 215    assert_eq!(
 216        visible_entries_as_strings(&panel, 0..50, cx),
 217        &[
 218            "v root1",
 219            "    > a",
 220            "    > b",
 221            "    > C",
 222            "      .dockerignore",
 223            "v root2",
 224            "    > d",
 225            "    > e",
 226        ]
 227    );
 228
 229    toggle_expand_dir(&panel, "root1/b", cx);
 230    assert_eq!(
 231        visible_entries_as_strings(&panel, 0..50, cx),
 232        &[
 233            "v root1",
 234            "    > a",
 235            "    v b  <== selected",
 236            "        > 3",
 237            "    > C",
 238            "      .dockerignore",
 239            "v root2",
 240            "    > d",
 241            "    > e",
 242        ]
 243    );
 244
 245    toggle_expand_dir(&panel, "root2/d", cx);
 246    assert_eq!(
 247        visible_entries_as_strings(&panel, 0..50, cx),
 248        &[
 249            "v root1",
 250            "    > a",
 251            "    v b",
 252            "        > 3",
 253            "    > C",
 254            "      .dockerignore",
 255            "v root2",
 256            "    v d  <== selected",
 257            "    > e",
 258        ]
 259    );
 260
 261    toggle_expand_dir(&panel, "root2/e", cx);
 262    assert_eq!(
 263        visible_entries_as_strings(&panel, 0..50, cx),
 264        &[
 265            "v root1",
 266            "    > a",
 267            "    v b",
 268            "        > 3",
 269            "    > C",
 270            "      .dockerignore",
 271            "v root2",
 272            "    v d",
 273            "    v e  <== selected",
 274        ]
 275    );
 276}
 277
 278#[gpui::test]
 279async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
 280    init_test(cx);
 281
 282    let fs = FakeFs::new(cx.executor());
 283    fs.insert_tree(
 284        path!("/root1"),
 285        json!({
 286            "dir_1": {
 287                "nested_dir_1": {
 288                    "nested_dir_2": {
 289                        "nested_dir_3": {
 290                            "file_a.java": "// File contents",
 291                            "file_b.java": "// File contents",
 292                            "file_c.java": "// File contents",
 293                            "nested_dir_4": {
 294                                "nested_dir_5": {
 295                                    "file_d.java": "// File contents",
 296                                }
 297                            }
 298                        }
 299                    }
 300                }
 301            }
 302        }),
 303    )
 304    .await;
 305    fs.insert_tree(
 306        path!("/root2"),
 307        json!({
 308            "dir_2": {
 309                "file_1.java": "// File contents",
 310            }
 311        }),
 312    )
 313    .await;
 314
 315    // Test 1: Multiple worktrees with auto_fold_dirs = true
 316    let project = Project::test(
 317        fs.clone(),
 318        [path!("/root1").as_ref(), path!("/root2").as_ref()],
 319        cx,
 320    )
 321    .await;
 322    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 323    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 324    cx.update(|_, cx| {
 325        let settings = *ProjectPanelSettings::get_global(cx);
 326        ProjectPanelSettings::override_global(
 327            ProjectPanelSettings {
 328                auto_fold_dirs: true,
 329                ..settings
 330            },
 331            cx,
 332        );
 333    });
 334    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 335    cx.run_until_parked();
 336    assert_eq!(
 337        visible_entries_as_strings(&panel, 0..10, cx),
 338        &[
 339            "v root1",
 340            "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 341            "v root2",
 342            "    > dir_2",
 343        ]
 344    );
 345
 346    toggle_expand_dir(
 347        &panel,
 348        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 349        cx,
 350    );
 351    assert_eq!(
 352        visible_entries_as_strings(&panel, 0..10, cx),
 353        &[
 354            "v root1",
 355            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
 356            "        > nested_dir_4/nested_dir_5",
 357            "          file_a.java",
 358            "          file_b.java",
 359            "          file_c.java",
 360            "v root2",
 361            "    > dir_2",
 362        ]
 363    );
 364
 365    toggle_expand_dir(
 366        &panel,
 367        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
 368        cx,
 369    );
 370    assert_eq!(
 371        visible_entries_as_strings(&panel, 0..10, cx),
 372        &[
 373            "v root1",
 374            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 375            "        v nested_dir_4/nested_dir_5  <== selected",
 376            "              file_d.java",
 377            "          file_a.java",
 378            "          file_b.java",
 379            "          file_c.java",
 380            "v root2",
 381            "    > dir_2",
 382        ]
 383    );
 384    toggle_expand_dir(&panel, "root2/dir_2", cx);
 385    assert_eq!(
 386        visible_entries_as_strings(&panel, 0..10, cx),
 387        &[
 388            "v root1",
 389            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 390            "        v nested_dir_4/nested_dir_5",
 391            "              file_d.java",
 392            "          file_a.java",
 393            "          file_b.java",
 394            "          file_c.java",
 395            "v root2",
 396            "    v dir_2  <== selected",
 397            "          file_1.java",
 398        ]
 399    );
 400
 401    // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
 402    {
 403        let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
 404        let workspace =
 405            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 406        let cx = &mut VisualTestContext::from_window(*workspace, cx);
 407        cx.update(|_, cx| {
 408            let settings = *ProjectPanelSettings::get_global(cx);
 409            ProjectPanelSettings::override_global(
 410                ProjectPanelSettings {
 411                    auto_fold_dirs: true,
 412                    hide_root: true,
 413                    ..settings
 414                },
 415                cx,
 416            );
 417        });
 418        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 419        cx.run_until_parked();
 420        assert_eq!(
 421            visible_entries_as_strings(&panel, 0..10, cx),
 422            &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
 423            "Single worktree with hide_root=true should hide root and show auto-folded paths"
 424        );
 425
 426        toggle_expand_dir(
 427            &panel,
 428            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 429            cx,
 430        );
 431        assert_eq!(
 432            visible_entries_as_strings(&panel, 0..10, cx),
 433            &[
 434                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
 435                "    > nested_dir_4/nested_dir_5",
 436                "      file_a.java",
 437                "      file_b.java",
 438                "      file_c.java",
 439            ],
 440            "Expanded auto-folded path with hidden root should show contents without root prefix"
 441        );
 442
 443        toggle_expand_dir(
 444            &panel,
 445            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
 446            cx,
 447        );
 448        assert_eq!(
 449            visible_entries_as_strings(&panel, 0..10, cx),
 450            &[
 451                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 452                "    v nested_dir_4/nested_dir_5  <== selected",
 453                "          file_d.java",
 454                "      file_a.java",
 455                "      file_b.java",
 456                "      file_c.java",
 457            ],
 458            "Nested expansion with hidden root should maintain proper indentation"
 459        );
 460    }
 461}
 462
 463#[gpui::test(iterations = 30)]
 464async fn test_editing_files(cx: &mut gpui::TestAppContext) {
 465    init_test(cx);
 466
 467    let fs = FakeFs::new(cx.executor());
 468    fs.insert_tree(
 469        "/root1",
 470        json!({
 471            ".dockerignore": "",
 472            ".git": {
 473                "HEAD": "",
 474            },
 475            "a": {
 476                "0": { "q": "", "r": "", "s": "" },
 477                "1": { "t": "", "u": "" },
 478                "2": { "v": "", "w": "", "x": "", "y": "" },
 479            },
 480            "b": {
 481                "3": { "Q": "" },
 482                "4": { "R": "", "S": "", "T": "", "U": "" },
 483            },
 484            "C": {
 485                "5": {},
 486                "6": { "V": "", "W": "" },
 487                "7": { "X": "" },
 488                "8": { "Y": {}, "Z": "" }
 489            }
 490        }),
 491    )
 492    .await;
 493    fs.insert_tree(
 494        "/root2",
 495        json!({
 496            "d": {
 497                "9": ""
 498            },
 499            "e": {}
 500        }),
 501    )
 502    .await;
 503
 504    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 505    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 506    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 507    let panel = workspace
 508        .update(cx, |workspace, window, cx| {
 509            let panel = ProjectPanel::new(workspace, window, cx);
 510            workspace.add_panel(panel.clone(), window, cx);
 511            panel
 512        })
 513        .unwrap();
 514    cx.run_until_parked();
 515
 516    select_path(&panel, "root1", cx);
 517    assert_eq!(
 518        visible_entries_as_strings(&panel, 0..10, cx),
 519        &[
 520            "v root1  <== selected",
 521            "    > .git",
 522            "    > a",
 523            "    > b",
 524            "    > C",
 525            "      .dockerignore",
 526            "v root2",
 527            "    > d",
 528            "    > e",
 529        ]
 530    );
 531
 532    // Add a file with the root folder selected. The filename editor is placed
 533    // before the first file in the root folder.
 534    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 535    cx.run_until_parked();
 536    panel.update_in(cx, |panel, window, cx| {
 537        assert!(panel.filename_editor.read(cx).is_focused(window));
 538    });
 539    assert_eq!(
 540        visible_entries_as_strings(&panel, 0..10, cx),
 541        &[
 542            "v root1",
 543            "    > .git",
 544            "    > a",
 545            "    > b",
 546            "    > C",
 547            "      [EDITOR: '']  <== selected",
 548            "      .dockerignore",
 549            "v root2",
 550            "    > d",
 551            "    > e",
 552        ]
 553    );
 554
 555    let confirm = panel.update_in(cx, |panel, window, cx| {
 556        panel.filename_editor.update(cx, |editor, cx| {
 557            editor.set_text("the-new-filename", window, cx)
 558        });
 559        panel.confirm_edit(window, cx).unwrap()
 560    });
 561    assert_eq!(
 562        visible_entries_as_strings(&panel, 0..10, cx),
 563        &[
 564            "v root1",
 565            "    > .git",
 566            "    > a",
 567            "    > b",
 568            "    > C",
 569            "      [PROCESSING: 'the-new-filename']  <== selected",
 570            "      .dockerignore",
 571            "v root2",
 572            "    > d",
 573            "    > e",
 574        ]
 575    );
 576
 577    confirm.await.unwrap();
 578    cx.run_until_parked();
 579    assert_eq!(
 580        visible_entries_as_strings(&panel, 0..10, cx),
 581        &[
 582            "v root1",
 583            "    > .git",
 584            "    > a",
 585            "    > b",
 586            "    > C",
 587            "      .dockerignore",
 588            "      the-new-filename  <== selected  <== marked",
 589            "v root2",
 590            "    > d",
 591            "    > e",
 592        ]
 593    );
 594
 595    select_path(&panel, "root1/b", cx);
 596    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 597    cx.run_until_parked();
 598    assert_eq!(
 599        visible_entries_as_strings(&panel, 0..10, cx),
 600        &[
 601            "v root1",
 602            "    > .git",
 603            "    > a",
 604            "    v b",
 605            "        > 3",
 606            "        > 4",
 607            "          [EDITOR: '']  <== selected",
 608            "    > C",
 609            "      .dockerignore",
 610            "      the-new-filename",
 611        ]
 612    );
 613
 614    panel
 615        .update_in(cx, |panel, window, cx| {
 616            panel.filename_editor.update(cx, |editor, cx| {
 617                editor.set_text("another-filename.txt", window, cx)
 618            });
 619            panel.confirm_edit(window, cx).unwrap()
 620        })
 621        .await
 622        .unwrap();
 623    cx.run_until_parked();
 624    assert_eq!(
 625        visible_entries_as_strings(&panel, 0..10, cx),
 626        &[
 627            "v root1",
 628            "    > .git",
 629            "    > a",
 630            "    v b",
 631            "        > 3",
 632            "        > 4",
 633            "          another-filename.txt  <== selected  <== marked",
 634            "    > C",
 635            "      .dockerignore",
 636            "      the-new-filename",
 637        ]
 638    );
 639
 640    select_path(&panel, "root1/b/another-filename.txt", cx);
 641    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 642    assert_eq!(
 643        visible_entries_as_strings(&panel, 0..10, cx),
 644        &[
 645            "v root1",
 646            "    > .git",
 647            "    > a",
 648            "    v b",
 649            "        > 3",
 650            "        > 4",
 651            "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
 652            "    > C",
 653            "      .dockerignore",
 654            "      the-new-filename",
 655        ]
 656    );
 657
 658    let confirm = panel.update_in(cx, |panel, window, cx| {
 659        panel.filename_editor.update(cx, |editor, cx| {
 660            let file_name_selections = editor.selections.all::<usize>(cx);
 661            assert_eq!(
 662                file_name_selections.len(),
 663                1,
 664                "File editing should have a single selection, but got: {file_name_selections:?}"
 665            );
 666            let file_name_selection = &file_name_selections[0];
 667            assert_eq!(
 668                file_name_selection.start, 0,
 669                "Should select the file name from the start"
 670            );
 671            assert_eq!(
 672                file_name_selection.end,
 673                "another-filename".len(),
 674                "Should not select file extension"
 675            );
 676
 677            editor.set_text("a-different-filename.tar.gz", window, cx)
 678        });
 679        panel.confirm_edit(window, cx).unwrap()
 680    });
 681    assert_eq!(
 682        visible_entries_as_strings(&panel, 0..10, cx),
 683        &[
 684            "v root1",
 685            "    > .git",
 686            "    > a",
 687            "    v b",
 688            "        > 3",
 689            "        > 4",
 690            "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
 691            "    > C",
 692            "      .dockerignore",
 693            "      the-new-filename",
 694        ]
 695    );
 696
 697    confirm.await.unwrap();
 698    cx.run_until_parked();
 699    assert_eq!(
 700        visible_entries_as_strings(&panel, 0..10, cx),
 701        &[
 702            "v root1",
 703            "    > .git",
 704            "    > a",
 705            "    v b",
 706            "        > 3",
 707            "        > 4",
 708            "          a-different-filename.tar.gz  <== selected",
 709            "    > C",
 710            "      .dockerignore",
 711            "      the-new-filename",
 712        ]
 713    );
 714
 715    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 716    assert_eq!(
 717        visible_entries_as_strings(&panel, 0..10, cx),
 718        &[
 719            "v root1",
 720            "    > .git",
 721            "    > a",
 722            "    v b",
 723            "        > 3",
 724            "        > 4",
 725            "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
 726            "    > C",
 727            "      .dockerignore",
 728            "      the-new-filename",
 729        ]
 730    );
 731
 732    panel.update_in(cx, |panel, window, cx| {
 733            panel.filename_editor.update(cx, |editor, cx| {
 734                let file_name_selections = editor.selections.all::<usize>(cx);
 735                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
 736                let file_name_selection = &file_name_selections[0];
 737                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
 738                assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
 739
 740            });
 741            panel.cancel(&menu::Cancel, window, cx)
 742        });
 743    cx.run_until_parked();
 744    panel.update_in(cx, |panel, window, cx| {
 745        panel.new_directory(&NewDirectory, window, cx)
 746    });
 747    cx.run_until_parked();
 748    assert_eq!(
 749        visible_entries_as_strings(&panel, 0..10, cx),
 750        &[
 751            "v root1",
 752            "    > .git",
 753            "    > a",
 754            "    v b",
 755            "        > [EDITOR: '']  <== selected",
 756            "        > 3",
 757            "        > 4",
 758            "          a-different-filename.tar.gz",
 759            "    > C",
 760            "      .dockerignore",
 761        ]
 762    );
 763
 764    let confirm = panel.update_in(cx, |panel, window, cx| {
 765        panel
 766            .filename_editor
 767            .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
 768        panel.confirm_edit(window, cx).unwrap()
 769    });
 770    panel.update_in(cx, |panel, window, cx| {
 771        panel.select_next(&Default::default(), window, cx)
 772    });
 773    assert_eq!(
 774        visible_entries_as_strings(&panel, 0..10, cx),
 775        &[
 776            "v root1",
 777            "    > .git",
 778            "    > a",
 779            "    v b",
 780            "        > [PROCESSING: 'new-dir']",
 781            "        > 3  <== selected",
 782            "        > 4",
 783            "          a-different-filename.tar.gz",
 784            "    > C",
 785            "      .dockerignore",
 786        ]
 787    );
 788
 789    confirm.await.unwrap();
 790    cx.run_until_parked();
 791    assert_eq!(
 792        visible_entries_as_strings(&panel, 0..10, cx),
 793        &[
 794            "v root1",
 795            "    > .git",
 796            "    > a",
 797            "    v b",
 798            "        > 3  <== selected",
 799            "        > 4",
 800            "        > new-dir",
 801            "          a-different-filename.tar.gz",
 802            "    > C",
 803            "      .dockerignore",
 804        ]
 805    );
 806
 807    panel.update_in(cx, |panel, window, cx| {
 808        panel.rename(&Default::default(), window, cx)
 809    });
 810    assert_eq!(
 811        visible_entries_as_strings(&panel, 0..10, cx),
 812        &[
 813            "v root1",
 814            "    > .git",
 815            "    > a",
 816            "    v b",
 817            "        > [EDITOR: '3']  <== selected",
 818            "        > 4",
 819            "        > new-dir",
 820            "          a-different-filename.tar.gz",
 821            "    > C",
 822            "      .dockerignore",
 823        ]
 824    );
 825
 826    // Dismiss the rename editor when it loses focus.
 827    workspace.update(cx, |_, window, _| window.blur()).unwrap();
 828    assert_eq!(
 829        visible_entries_as_strings(&panel, 0..10, cx),
 830        &[
 831            "v root1",
 832            "    > .git",
 833            "    > a",
 834            "    v b",
 835            "        > 3  <== selected",
 836            "        > 4",
 837            "        > new-dir",
 838            "          a-different-filename.tar.gz",
 839            "    > C",
 840            "      .dockerignore",
 841        ]
 842    );
 843
 844    // Test empty filename and filename with only whitespace
 845    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 846    cx.run_until_parked();
 847    assert_eq!(
 848        visible_entries_as_strings(&panel, 0..10, cx),
 849        &[
 850            "v root1",
 851            "    > .git",
 852            "    > a",
 853            "    v b",
 854            "        v 3",
 855            "              [EDITOR: '']  <== selected",
 856            "              Q",
 857            "        > 4",
 858            "        > new-dir",
 859            "          a-different-filename.tar.gz",
 860        ]
 861    );
 862    panel.update_in(cx, |panel, window, cx| {
 863        panel.filename_editor.update(cx, |editor, cx| {
 864            editor.set_text("", window, cx);
 865        });
 866        assert!(panel.confirm_edit(window, cx).is_none());
 867        panel.filename_editor.update(cx, |editor, cx| {
 868            editor.set_text("   ", window, cx);
 869        });
 870        assert!(panel.confirm_edit(window, cx).is_none());
 871        panel.cancel(&menu::Cancel, window, cx);
 872        panel.update_visible_entries(None, false, false, window, cx);
 873    });
 874    cx.run_until_parked();
 875    assert_eq!(
 876        visible_entries_as_strings(&panel, 0..10, cx),
 877        &[
 878            "v root1",
 879            "    > .git",
 880            "    > a",
 881            "    v b",
 882            "        v 3  <== selected",
 883            "              Q",
 884            "        > 4",
 885            "        > new-dir",
 886            "          a-different-filename.tar.gz",
 887            "    > C",
 888        ]
 889    );
 890}
 891
 892#[gpui::test(iterations = 10)]
 893async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
 894    init_test(cx);
 895
 896    let fs = FakeFs::new(cx.executor());
 897    fs.insert_tree(
 898        "/root1",
 899        json!({
 900            ".dockerignore": "",
 901            ".git": {
 902                "HEAD": "",
 903            },
 904            "a": {
 905                "0": { "q": "", "r": "", "s": "" },
 906                "1": { "t": "", "u": "" },
 907                "2": { "v": "", "w": "", "x": "", "y": "" },
 908            },
 909            "b": {
 910                "3": { "Q": "" },
 911                "4": { "R": "", "S": "", "T": "", "U": "" },
 912            },
 913            "C": {
 914                "5": {},
 915                "6": { "V": "", "W": "" },
 916                "7": { "X": "" },
 917                "8": { "Y": {}, "Z": "" }
 918            }
 919        }),
 920    )
 921    .await;
 922    fs.insert_tree(
 923        "/root2",
 924        json!({
 925            "d": {
 926                "9": ""
 927            },
 928            "e": {}
 929        }),
 930    )
 931    .await;
 932
 933    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 934    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 935    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 936    let panel = workspace
 937        .update(cx, |workspace, window, cx| {
 938            let panel = ProjectPanel::new(workspace, window, cx);
 939            workspace.add_panel(panel.clone(), window, cx);
 940            panel
 941        })
 942        .unwrap();
 943    cx.run_until_parked();
 944
 945    select_path(&panel, "root1", cx);
 946    assert_eq!(
 947        visible_entries_as_strings(&panel, 0..10, cx),
 948        &[
 949            "v root1  <== selected",
 950            "    > .git",
 951            "    > a",
 952            "    > b",
 953            "    > C",
 954            "      .dockerignore",
 955            "v root2",
 956            "    > d",
 957            "    > e",
 958        ]
 959    );
 960
 961    // Add a file with the root folder selected. The filename editor is placed
 962    // before the first file in the root folder.
 963    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 964    cx.run_until_parked();
 965    panel.update_in(cx, |panel, window, cx| {
 966        assert!(panel.filename_editor.read(cx).is_focused(window));
 967    });
 968    cx.run_until_parked();
 969    assert_eq!(
 970        visible_entries_as_strings(&panel, 0..10, cx),
 971        &[
 972            "v root1",
 973            "    > .git",
 974            "    > a",
 975            "    > b",
 976            "    > C",
 977            "      [EDITOR: '']  <== selected",
 978            "      .dockerignore",
 979            "v root2",
 980            "    > d",
 981            "    > e",
 982        ]
 983    );
 984
 985    let confirm = panel.update_in(cx, |panel, window, cx| {
 986        panel.filename_editor.update(cx, |editor, cx| {
 987            editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
 988        });
 989        panel.confirm_edit(window, cx).unwrap()
 990    });
 991
 992    assert_eq!(
 993        visible_entries_as_strings(&panel, 0..10, cx),
 994        &[
 995            "v root1",
 996            "    > .git",
 997            "    > a",
 998            "    > b",
 999            "    > C",
1000            "      [PROCESSING: 'bdir1/dir2/the-new-filename']  <== selected",
1001            "      .dockerignore",
1002            "v root2",
1003            "    > d",
1004            "    > e",
1005        ]
1006    );
1007
1008    confirm.await.unwrap();
1009    cx.run_until_parked();
1010    assert_eq!(
1011        visible_entries_as_strings(&panel, 0..13, cx),
1012        &[
1013            "v root1",
1014            "    > .git",
1015            "    > a",
1016            "    > b",
1017            "    v bdir1",
1018            "        v dir2",
1019            "              the-new-filename  <== selected  <== marked",
1020            "    > C",
1021            "      .dockerignore",
1022            "v root2",
1023            "    > d",
1024            "    > e",
1025        ]
1026    );
1027}
1028
1029#[gpui::test]
1030async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
1031    init_test(cx);
1032
1033    let fs = FakeFs::new(cx.executor());
1034    fs.insert_tree(
1035        path!("/root1"),
1036        json!({
1037            ".dockerignore": "",
1038            ".git": {
1039                "HEAD": "",
1040            },
1041        }),
1042    )
1043    .await;
1044
1045    let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
1046    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1047    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1048    let panel = workspace
1049        .update(cx, |workspace, window, cx| {
1050            let panel = ProjectPanel::new(workspace, window, cx);
1051            workspace.add_panel(panel.clone(), window, cx);
1052            panel
1053        })
1054        .unwrap();
1055    cx.run_until_parked();
1056
1057    select_path(&panel, "root1", cx);
1058    assert_eq!(
1059        visible_entries_as_strings(&panel, 0..10, cx),
1060        &["v root1  <== selected", "    > .git", "      .dockerignore",]
1061    );
1062
1063    // Add a file with the root folder selected. The filename editor is placed
1064    // before the first file in the root folder.
1065    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1066    cx.run_until_parked();
1067    panel.update_in(cx, |panel, window, cx| {
1068        assert!(panel.filename_editor.read(cx).is_focused(window));
1069    });
1070    assert_eq!(
1071        visible_entries_as_strings(&panel, 0..10, cx),
1072        &[
1073            "v root1",
1074            "    > .git",
1075            "      [EDITOR: '']  <== selected",
1076            "      .dockerignore",
1077        ]
1078    );
1079
1080    let confirm = panel.update_in(cx, |panel, window, cx| {
1081        // If we want to create a subdirectory, there should be no prefix slash.
1082        panel
1083            .filename_editor
1084            .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
1085        panel.confirm_edit(window, cx).unwrap()
1086    });
1087
1088    assert_eq!(
1089        visible_entries_as_strings(&panel, 0..10, cx),
1090        &[
1091            "v root1",
1092            "    > .git",
1093            "      [PROCESSING: 'new_dir']  <== selected",
1094            "      .dockerignore",
1095        ]
1096    );
1097
1098    confirm.await.unwrap();
1099    cx.run_until_parked();
1100    assert_eq!(
1101        visible_entries_as_strings(&panel, 0..10, cx),
1102        &[
1103            "v root1",
1104            "    > .git",
1105            "    v new_dir  <== selected",
1106            "      .dockerignore",
1107        ]
1108    );
1109
1110    // Test filename with whitespace
1111    select_path(&panel, "root1", cx);
1112    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1113    let confirm = panel.update_in(cx, |panel, window, cx| {
1114        // If we want to create a subdirectory, there should be no prefix slash.
1115        panel
1116            .filename_editor
1117            .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
1118        panel.confirm_edit(window, cx).unwrap()
1119    });
1120    confirm.await.unwrap();
1121    cx.run_until_parked();
1122    assert_eq!(
1123        visible_entries_as_strings(&panel, 0..10, cx),
1124        &[
1125            "v root1",
1126            "    > .git",
1127            "    v new dir 2  <== selected",
1128            "    v new_dir",
1129            "      .dockerignore",
1130        ]
1131    );
1132
1133    // Test filename ends with "\"
1134    #[cfg(target_os = "windows")]
1135    {
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_3\\", window, cx));
1143            panel.confirm_edit(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",
1153                "    v new_dir",
1154                "    v new_dir_3  <== selected",
1155                "      .dockerignore",
1156            ]
1157        );
1158    }
1159}
1160
1161#[gpui::test]
1162async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1163    init_test(cx);
1164
1165    let fs = FakeFs::new(cx.executor());
1166    fs.insert_tree(
1167        "/root1",
1168        json!({
1169            "one.two.txt": "",
1170            "one.txt": ""
1171        }),
1172    )
1173    .await;
1174
1175    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1176    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1177    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1178    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1179    cx.run_until_parked();
1180
1181    panel.update_in(cx, |panel, window, cx| {
1182        panel.select_next(&Default::default(), window, cx);
1183        panel.select_next(&Default::default(), window, cx);
1184    });
1185
1186    assert_eq!(
1187        visible_entries_as_strings(&panel, 0..50, cx),
1188        &[
1189            //
1190            "v root1",
1191            "      one.txt  <== selected",
1192            "      one.two.txt",
1193        ]
1194    );
1195
1196    // Regression test - file name is created correctly when
1197    // the copied file's name contains multiple dots.
1198    panel.update_in(cx, |panel, window, cx| {
1199        panel.copy(&Default::default(), window, cx);
1200        panel.paste(&Default::default(), window, cx);
1201    });
1202    cx.executor().run_until_parked();
1203
1204    assert_eq!(
1205        visible_entries_as_strings(&panel, 0..50, cx),
1206        &[
1207            //
1208            "v root1",
1209            "      one.txt",
1210            "      [EDITOR: 'one copy.txt']  <== selected  <== marked",
1211            "      one.two.txt",
1212        ]
1213    );
1214
1215    panel.update_in(cx, |panel, window, cx| {
1216        panel.filename_editor.update(cx, |editor, cx| {
1217            let file_name_selections = editor.selections.all::<usize>(cx);
1218            assert_eq!(
1219                file_name_selections.len(),
1220                1,
1221                "File editing should have a single selection, but got: {file_name_selections:?}"
1222            );
1223            let file_name_selection = &file_name_selections[0];
1224            assert_eq!(
1225                file_name_selection.start,
1226                "one".len(),
1227                "Should select the file name disambiguation after the original file name"
1228            );
1229            assert_eq!(
1230                file_name_selection.end,
1231                "one copy".len(),
1232                "Should select the file name disambiguation until the extension"
1233            );
1234        });
1235        assert!(panel.confirm_edit(window, cx).is_none());
1236    });
1237
1238    panel.update_in(cx, |panel, window, cx| {
1239        panel.paste(&Default::default(), window, cx);
1240    });
1241    cx.executor().run_until_parked();
1242
1243    assert_eq!(
1244        visible_entries_as_strings(&panel, 0..50, cx),
1245        &[
1246            //
1247            "v root1",
1248            "      one.txt",
1249            "      one copy.txt",
1250            "      [EDITOR: 'one copy 1.txt']  <== selected  <== marked",
1251            "      one.two.txt",
1252        ]
1253    );
1254
1255    panel.update_in(cx, |panel, window, cx| {
1256        assert!(panel.confirm_edit(window, cx).is_none())
1257    });
1258}
1259
1260#[gpui::test]
1261async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
1262    init_test(cx);
1263
1264    let fs = FakeFs::new(cx.executor());
1265    fs.insert_tree(
1266        "/root",
1267        json!({
1268            "one.txt": "",
1269            "two.txt": "",
1270            "a": {},
1271            "b": {}
1272        }),
1273    )
1274    .await;
1275
1276    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1277    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1278    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1279    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1280    cx.run_until_parked();
1281
1282    select_path_with_mark(&panel, "root/one.txt", cx);
1283    select_path_with_mark(&panel, "root/two.txt", cx);
1284
1285    assert_eq!(
1286        visible_entries_as_strings(&panel, 0..50, cx),
1287        &[
1288            "v root",
1289            "    > a",
1290            "    > b",
1291            "      one.txt  <== marked",
1292            "      two.txt  <== selected  <== marked",
1293        ]
1294    );
1295
1296    panel.update_in(cx, |panel, window, cx| {
1297        panel.cut(&Default::default(), window, cx);
1298    });
1299
1300    select_path(&panel, "root/a", cx);
1301
1302    panel.update_in(cx, |panel, window, cx| {
1303        panel.paste(&Default::default(), window, cx);
1304        panel.update_visible_entries(None, false, false, window, cx);
1305    });
1306    cx.executor().run_until_parked();
1307
1308    assert_eq!(
1309        visible_entries_as_strings(&panel, 0..50, cx),
1310        &[
1311            "v root",
1312            "    v a",
1313            "          one.txt  <== marked",
1314            "          two.txt  <== selected  <== marked",
1315            "    > b",
1316        ],
1317        "Cut entries should be moved on first paste."
1318    );
1319
1320    panel.update_in(cx, |panel, window, cx| {
1321        panel.cancel(&menu::Cancel {}, window, cx)
1322    });
1323    cx.executor().run_until_parked();
1324
1325    select_path(&panel, "root/b", cx);
1326
1327    panel.update_in(cx, |panel, window, cx| {
1328        panel.paste(&Default::default(), window, cx);
1329    });
1330    cx.executor().run_until_parked();
1331
1332    assert_eq!(
1333        visible_entries_as_strings(&panel, 0..50, cx),
1334        &[
1335            "v root",
1336            "    v a",
1337            "          one.txt",
1338            "          two.txt",
1339            "    v b",
1340            "          one.txt",
1341            "          two.txt  <== selected",
1342        ],
1343        "Cut entries should only be copied for the second paste!"
1344    );
1345}
1346
1347#[gpui::test]
1348async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1349    init_test(cx);
1350
1351    let fs = FakeFs::new(cx.executor());
1352    fs.insert_tree(
1353        "/root1",
1354        json!({
1355            "one.txt": "",
1356            "two.txt": "",
1357            "three.txt": "",
1358            "a": {
1359                "0": { "q": "", "r": "", "s": "" },
1360                "1": { "t": "", "u": "" },
1361                "2": { "v": "", "w": "", "x": "", "y": "" },
1362            },
1363        }),
1364    )
1365    .await;
1366
1367    fs.insert_tree(
1368        "/root2",
1369        json!({
1370            "one.txt": "",
1371            "two.txt": "",
1372            "four.txt": "",
1373            "b": {
1374                "3": { "Q": "" },
1375                "4": { "R": "", "S": "", "T": "", "U": "" },
1376            },
1377        }),
1378    )
1379    .await;
1380
1381    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1382    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1383    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1384    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1385    cx.run_until_parked();
1386
1387    select_path(&panel, "root1/three.txt", cx);
1388    panel.update_in(cx, |panel, window, cx| {
1389        panel.cut(&Default::default(), window, cx);
1390    });
1391
1392    select_path(&panel, "root2/one.txt", cx);
1393    panel.update_in(cx, |panel, window, cx| {
1394        panel.select_next(&Default::default(), window, cx);
1395        panel.paste(&Default::default(), window, cx);
1396    });
1397    cx.executor().run_until_parked();
1398    assert_eq!(
1399        visible_entries_as_strings(&panel, 0..50, cx),
1400        &[
1401            //
1402            "v root1",
1403            "    > a",
1404            "      one.txt",
1405            "      two.txt",
1406            "v root2",
1407            "    > b",
1408            "      four.txt",
1409            "      one.txt",
1410            "      three.txt  <== selected  <== marked",
1411            "      two.txt",
1412        ]
1413    );
1414
1415    select_path(&panel, "root1/a", cx);
1416    panel.update_in(cx, |panel, window, cx| {
1417        panel.cut(&Default::default(), window, cx);
1418    });
1419    select_path(&panel, "root2/two.txt", cx);
1420    panel.update_in(cx, |panel, window, cx| {
1421        panel.select_next(&Default::default(), window, cx);
1422        panel.paste(&Default::default(), window, cx);
1423    });
1424
1425    cx.executor().run_until_parked();
1426    assert_eq!(
1427        visible_entries_as_strings(&panel, 0..50, cx),
1428        &[
1429            //
1430            "v root1",
1431            "      one.txt",
1432            "      two.txt",
1433            "v root2",
1434            "    > a  <== selected",
1435            "    > b",
1436            "      four.txt",
1437            "      one.txt",
1438            "      three.txt  <== marked",
1439            "      two.txt",
1440        ]
1441    );
1442}
1443
1444#[gpui::test]
1445async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1446    init_test(cx);
1447
1448    let fs = FakeFs::new(cx.executor());
1449    fs.insert_tree(
1450        "/root1",
1451        json!({
1452            "one.txt": "",
1453            "two.txt": "",
1454            "three.txt": "",
1455            "a": {
1456                "0": { "q": "", "r": "", "s": "" },
1457                "1": { "t": "", "u": "" },
1458                "2": { "v": "", "w": "", "x": "", "y": "" },
1459            },
1460        }),
1461    )
1462    .await;
1463
1464    fs.insert_tree(
1465        "/root2",
1466        json!({
1467            "one.txt": "",
1468            "two.txt": "",
1469            "four.txt": "",
1470            "b": {
1471                "3": { "Q": "" },
1472                "4": { "R": "", "S": "", "T": "", "U": "" },
1473            },
1474        }),
1475    )
1476    .await;
1477
1478    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1479    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1480    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1481    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1482    cx.run_until_parked();
1483
1484    select_path(&panel, "root1/three.txt", cx);
1485    panel.update_in(cx, |panel, window, cx| {
1486        panel.copy(&Default::default(), window, cx);
1487    });
1488
1489    select_path(&panel, "root2/one.txt", cx);
1490    panel.update_in(cx, |panel, window, cx| {
1491        panel.select_next(&Default::default(), window, cx);
1492        panel.paste(&Default::default(), window, cx);
1493    });
1494    cx.executor().run_until_parked();
1495    assert_eq!(
1496        visible_entries_as_strings(&panel, 0..50, cx),
1497        &[
1498            //
1499            "v root1",
1500            "    > a",
1501            "      one.txt",
1502            "      three.txt",
1503            "      two.txt",
1504            "v root2",
1505            "    > b",
1506            "      four.txt",
1507            "      one.txt",
1508            "      three.txt  <== selected  <== marked",
1509            "      two.txt",
1510        ]
1511    );
1512
1513    select_path(&panel, "root1/three.txt", cx);
1514    panel.update_in(cx, |panel, window, cx| {
1515        panel.copy(&Default::default(), window, cx);
1516    });
1517    select_path(&panel, "root2/two.txt", cx);
1518    panel.update_in(cx, |panel, window, cx| {
1519        panel.select_next(&Default::default(), window, cx);
1520        panel.paste(&Default::default(), window, cx);
1521    });
1522
1523    cx.executor().run_until_parked();
1524    assert_eq!(
1525        visible_entries_as_strings(&panel, 0..50, cx),
1526        &[
1527            //
1528            "v root1",
1529            "    > a",
1530            "      one.txt",
1531            "      three.txt",
1532            "      two.txt",
1533            "v root2",
1534            "    > b",
1535            "      four.txt",
1536            "      one.txt",
1537            "      three.txt",
1538            "      [EDITOR: 'three copy.txt']  <== selected  <== marked",
1539            "      two.txt",
1540        ]
1541    );
1542
1543    panel.update_in(cx, |panel, window, cx| {
1544        panel.cancel(&menu::Cancel {}, window, cx)
1545    });
1546    cx.executor().run_until_parked();
1547
1548    select_path(&panel, "root1/a", cx);
1549    panel.update_in(cx, |panel, window, cx| {
1550        panel.copy(&Default::default(), window, cx);
1551    });
1552    select_path(&panel, "root2/two.txt", cx);
1553    panel.update_in(cx, |panel, window, cx| {
1554        panel.select_next(&Default::default(), window, cx);
1555        panel.paste(&Default::default(), window, cx);
1556    });
1557
1558    cx.executor().run_until_parked();
1559    assert_eq!(
1560        visible_entries_as_strings(&panel, 0..50, cx),
1561        &[
1562            //
1563            "v root1",
1564            "    > a",
1565            "      one.txt",
1566            "      three.txt",
1567            "      two.txt",
1568            "v root2",
1569            "    > a  <== selected",
1570            "    > b",
1571            "      four.txt",
1572            "      one.txt",
1573            "      three.txt",
1574            "      three copy.txt",
1575            "      two.txt",
1576        ]
1577    );
1578}
1579
1580#[gpui::test]
1581async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1582    init_test(cx);
1583
1584    let fs = FakeFs::new(cx.executor());
1585    fs.insert_tree(
1586        "/root",
1587        json!({
1588            "a": {
1589                "one.txt": "",
1590                "two.txt": "",
1591                "inner_dir": {
1592                    "three.txt": "",
1593                    "four.txt": "",
1594                }
1595            },
1596            "b": {}
1597        }),
1598    )
1599    .await;
1600
1601    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1602    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1603    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1604    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1605    cx.run_until_parked();
1606
1607    select_path(&panel, "root/a", cx);
1608    panel.update_in(cx, |panel, window, cx| {
1609        panel.copy(&Default::default(), window, cx);
1610        panel.select_next(&Default::default(), window, cx);
1611        panel.paste(&Default::default(), window, cx);
1612    });
1613    cx.executor().run_until_parked();
1614
1615    let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1616    assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1617
1618    let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1619    assert_ne!(
1620        pasted_dir_file, None,
1621        "Pasted directory file should have an entry"
1622    );
1623
1624    let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1625    assert_ne!(
1626        pasted_dir_inner_dir, None,
1627        "Directories inside pasted directory should have an entry"
1628    );
1629
1630    toggle_expand_dir(&panel, "root/b/a", cx);
1631    toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1632
1633    assert_eq!(
1634        visible_entries_as_strings(&panel, 0..50, cx),
1635        &[
1636            //
1637            "v root",
1638            "    > a",
1639            "    v b",
1640            "        v a",
1641            "            v inner_dir  <== selected",
1642            "                  four.txt",
1643            "                  three.txt",
1644            "              one.txt",
1645            "              two.txt",
1646        ]
1647    );
1648
1649    select_path(&panel, "root", cx);
1650    panel.update_in(cx, |panel, window, cx| {
1651        panel.paste(&Default::default(), window, cx)
1652    });
1653    cx.executor().run_until_parked();
1654    assert_eq!(
1655        visible_entries_as_strings(&panel, 0..50, cx),
1656        &[
1657            //
1658            "v root",
1659            "    > a",
1660            "    > [EDITOR: 'a copy']  <== selected",
1661            "    v b",
1662            "        v a",
1663            "            v inner_dir",
1664            "                  four.txt",
1665            "                  three.txt",
1666            "              one.txt",
1667            "              two.txt"
1668        ]
1669    );
1670
1671    let confirm = panel.update_in(cx, |panel, window, cx| {
1672        panel
1673            .filename_editor
1674            .update(cx, |editor, cx| editor.set_text("c", window, cx));
1675        panel.confirm_edit(window, cx).unwrap()
1676    });
1677    assert_eq!(
1678        visible_entries_as_strings(&panel, 0..50, cx),
1679        &[
1680            //
1681            "v root",
1682            "    > a",
1683            "    > [PROCESSING: 'c']  <== selected",
1684            "    v b",
1685            "        v a",
1686            "            v inner_dir",
1687            "                  four.txt",
1688            "                  three.txt",
1689            "              one.txt",
1690            "              two.txt"
1691        ]
1692    );
1693
1694    confirm.await.unwrap();
1695
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            "    v b",
1707            "        v a",
1708            "            v inner_dir",
1709            "                  four.txt",
1710            "                  three.txt",
1711            "              one.txt",
1712            "              two.txt",
1713            "    v c",
1714            "        > a  <== selected",
1715            "        > inner_dir",
1716            "          one.txt",
1717            "          two.txt",
1718        ]
1719    );
1720}
1721
1722#[gpui::test]
1723async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
1724    init_test(cx);
1725
1726    let fs = FakeFs::new(cx.executor());
1727    fs.insert_tree(
1728        "/test",
1729        json!({
1730            "dir1": {
1731                "a.txt": "",
1732                "b.txt": "",
1733            },
1734            "dir2": {},
1735            "c.txt": "",
1736            "d.txt": "",
1737        }),
1738    )
1739    .await;
1740
1741    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1742    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1743    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1744    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1745    cx.run_until_parked();
1746
1747    toggle_expand_dir(&panel, "test/dir1", cx);
1748
1749    cx.simulate_modifiers_change(gpui::Modifiers {
1750        control: true,
1751        ..Default::default()
1752    });
1753
1754    select_path_with_mark(&panel, "test/dir1", cx);
1755    select_path_with_mark(&panel, "test/c.txt", cx);
1756
1757    assert_eq!(
1758        visible_entries_as_strings(&panel, 0..15, cx),
1759        &[
1760            "v test",
1761            "    v dir1  <== marked",
1762            "          a.txt",
1763            "          b.txt",
1764            "    > dir2",
1765            "      c.txt  <== selected  <== marked",
1766            "      d.txt",
1767        ],
1768        "Initial state before copying dir1 and c.txt"
1769    );
1770
1771    panel.update_in(cx, |panel, window, cx| {
1772        panel.copy(&Default::default(), window, cx);
1773    });
1774    select_path(&panel, "test/dir2", cx);
1775    panel.update_in(cx, |panel, window, cx| {
1776        panel.paste(&Default::default(), window, cx);
1777    });
1778    cx.executor().run_until_parked();
1779
1780    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1781
1782    assert_eq!(
1783        visible_entries_as_strings(&panel, 0..15, cx),
1784        &[
1785            "v test",
1786            "    v dir1  <== marked",
1787            "          a.txt",
1788            "          b.txt",
1789            "    v dir2",
1790            "        v dir1  <== selected",
1791            "              a.txt",
1792            "              b.txt",
1793            "          c.txt",
1794            "      c.txt  <== marked",
1795            "      d.txt",
1796        ],
1797        "Should copy dir1 as well as c.txt into dir2"
1798    );
1799
1800    // Disambiguating multiple files should not open the rename editor.
1801    select_path(&panel, "test/dir2", cx);
1802    panel.update_in(cx, |panel, window, cx| {
1803        panel.paste(&Default::default(), window, cx);
1804    });
1805    cx.executor().run_until_parked();
1806
1807    assert_eq!(
1808        visible_entries_as_strings(&panel, 0..15, cx),
1809        &[
1810            "v test",
1811            "    v dir1  <== marked",
1812            "          a.txt",
1813            "          b.txt",
1814            "    v dir2",
1815            "        v dir1",
1816            "              a.txt",
1817            "              b.txt",
1818            "        > dir1 copy  <== selected",
1819            "          c.txt",
1820            "          c copy.txt",
1821            "      c.txt  <== marked",
1822            "      d.txt",
1823        ],
1824        "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
1825    );
1826}
1827
1828#[gpui::test]
1829async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
1830    init_test(cx);
1831
1832    let fs = FakeFs::new(cx.executor());
1833    fs.insert_tree(
1834        "/test",
1835        json!({
1836            "dir1": {
1837                "a.txt": "",
1838                "b.txt": "",
1839            },
1840            "dir2": {},
1841            "c.txt": "",
1842            "d.txt": "",
1843        }),
1844    )
1845    .await;
1846
1847    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1848    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1849    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1850    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1851    cx.run_until_parked();
1852
1853    toggle_expand_dir(&panel, "test/dir1", cx);
1854
1855    cx.simulate_modifiers_change(gpui::Modifiers {
1856        control: true,
1857        ..Default::default()
1858    });
1859
1860    select_path_with_mark(&panel, "test/dir1/a.txt", cx);
1861    select_path_with_mark(&panel, "test/dir1", cx);
1862    select_path_with_mark(&panel, "test/c.txt", cx);
1863
1864    assert_eq!(
1865        visible_entries_as_strings(&panel, 0..15, cx),
1866        &[
1867            "v test",
1868            "    v dir1  <== marked",
1869            "          a.txt  <== marked",
1870            "          b.txt",
1871            "    > dir2",
1872            "      c.txt  <== selected  <== marked",
1873            "      d.txt",
1874        ],
1875        "Initial state before copying a.txt, dir1 and c.txt"
1876    );
1877
1878    panel.update_in(cx, |panel, window, cx| {
1879        panel.copy(&Default::default(), window, cx);
1880    });
1881    select_path(&panel, "test/dir2", cx);
1882    panel.update_in(cx, |panel, window, cx| {
1883        panel.paste(&Default::default(), window, cx);
1884    });
1885    cx.executor().run_until_parked();
1886
1887    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1888
1889    assert_eq!(
1890        visible_entries_as_strings(&panel, 0..20, cx),
1891        &[
1892            "v test",
1893            "    v dir1  <== marked",
1894            "          a.txt  <== marked",
1895            "          b.txt",
1896            "    v dir2",
1897            "        v dir1  <== selected",
1898            "              a.txt",
1899            "              b.txt",
1900            "          c.txt",
1901            "      c.txt  <== marked",
1902            "      d.txt",
1903        ],
1904        "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
1905    );
1906}
1907
1908#[gpui::test]
1909async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1910    init_test_with_editor(cx);
1911
1912    let fs = FakeFs::new(cx.executor());
1913    fs.insert_tree(
1914        path!("/src"),
1915        json!({
1916            "test": {
1917                "first.rs": "// First Rust file",
1918                "second.rs": "// Second Rust file",
1919                "third.rs": "// Third Rust file",
1920            }
1921        }),
1922    )
1923    .await;
1924
1925    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
1926    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1927    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1928    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1929    cx.run_until_parked();
1930
1931    toggle_expand_dir(&panel, "src/test", cx);
1932    select_path(&panel, "src/test/first.rs", cx);
1933    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1934    cx.executor().run_until_parked();
1935    assert_eq!(
1936        visible_entries_as_strings(&panel, 0..10, cx),
1937        &[
1938            "v src",
1939            "    v test",
1940            "          first.rs  <== selected  <== marked",
1941            "          second.rs",
1942            "          third.rs"
1943        ]
1944    );
1945    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
1946
1947    submit_deletion(&panel, cx);
1948    assert_eq!(
1949        visible_entries_as_strings(&panel, 0..10, cx),
1950        &[
1951            "v src",
1952            "    v test",
1953            "          second.rs  <== selected",
1954            "          third.rs"
1955        ],
1956        "Project panel should have no deleted file, no other file is selected in it"
1957    );
1958    ensure_no_open_items_and_panes(&workspace, cx);
1959
1960    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1961    cx.executor().run_until_parked();
1962    assert_eq!(
1963        visible_entries_as_strings(&panel, 0..10, cx),
1964        &[
1965            "v src",
1966            "    v test",
1967            "          second.rs  <== selected  <== marked",
1968            "          third.rs"
1969        ]
1970    );
1971    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
1972
1973    workspace
1974        .update(cx, |workspace, window, cx| {
1975            let active_items = workspace
1976                .panes()
1977                .iter()
1978                .filter_map(|pane| pane.read(cx).active_item())
1979                .collect::<Vec<_>>();
1980            assert_eq!(active_items.len(), 1);
1981            let open_editor = active_items
1982                .into_iter()
1983                .next()
1984                .unwrap()
1985                .downcast::<Editor>()
1986                .expect("Open item should be an editor");
1987            open_editor.update(cx, |editor, cx| {
1988                editor.set_text("Another text!", window, cx)
1989            });
1990        })
1991        .unwrap();
1992    submit_deletion_skipping_prompt(&panel, cx);
1993    assert_eq!(
1994        visible_entries_as_strings(&panel, 0..10, cx),
1995        &["v src", "    v test", "          third.rs  <== selected"],
1996        "Project panel should have no deleted file, with one last file remaining"
1997    );
1998    ensure_no_open_items_and_panes(&workspace, cx);
1999}
2000
2001#[gpui::test]
2002async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2003    init_test_with_editor(cx);
2004
2005    let fs = FakeFs::new(cx.executor());
2006    fs.insert_tree(
2007        "/src",
2008        json!({
2009            "test": {
2010                "first.rs": "// First Rust file",
2011                "second.rs": "// Second Rust file",
2012                "third.rs": "// Third Rust file",
2013            }
2014        }),
2015    )
2016    .await;
2017
2018    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2019    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2020    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2021    let panel = workspace
2022        .update(cx, |workspace, window, cx| {
2023            let panel = ProjectPanel::new(workspace, window, cx);
2024            workspace.add_panel(panel.clone(), window, cx);
2025            panel
2026        })
2027        .unwrap();
2028    cx.run_until_parked();
2029
2030    select_path(&panel, "src", cx);
2031    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2032    cx.executor().run_until_parked();
2033    assert_eq!(
2034        visible_entries_as_strings(&panel, 0..10, cx),
2035        &[
2036            //
2037            "v src  <== selected",
2038            "    > test"
2039        ]
2040    );
2041    panel.update_in(cx, |panel, window, cx| {
2042        panel.new_directory(&NewDirectory, window, cx)
2043    });
2044    cx.run_until_parked();
2045    panel.update_in(cx, |panel, window, cx| {
2046        assert!(panel.filename_editor.read(cx).is_focused(window));
2047    });
2048    cx.executor().run_until_parked();
2049    assert_eq!(
2050        visible_entries_as_strings(&panel, 0..10, cx),
2051        &[
2052            //
2053            "v src",
2054            "    > [EDITOR: '']  <== selected",
2055            "    > test"
2056        ]
2057    );
2058    panel.update_in(cx, |panel, window, cx| {
2059        panel
2060            .filename_editor
2061            .update(cx, |editor, cx| editor.set_text("test", window, cx));
2062        assert!(
2063            panel.confirm_edit(window, cx).is_none(),
2064            "Should not allow to confirm on conflicting new directory name"
2065        );
2066    });
2067    cx.executor().run_until_parked();
2068    panel.update_in(cx, |panel, window, cx| {
2069        assert!(
2070            panel.state.edit_state.is_some(),
2071            "Edit state should not be None after conflicting new directory name"
2072        );
2073        panel.cancel(&menu::Cancel, window, cx);
2074        panel.update_visible_entries(None, false, false, window, cx);
2075    });
2076    cx.run_until_parked();
2077    assert_eq!(
2078        visible_entries_as_strings(&panel, 0..10, cx),
2079        &[
2080            //
2081            "v src  <== selected",
2082            "    > test"
2083        ],
2084        "File list should be unchanged after failed folder create confirmation"
2085    );
2086
2087    select_path(&panel, "src/test", cx);
2088    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2089    cx.executor().run_until_parked();
2090    assert_eq!(
2091        visible_entries_as_strings(&panel, 0..10, cx),
2092        &[
2093            //
2094            "v src",
2095            "    > test  <== selected"
2096        ]
2097    );
2098    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2099    cx.run_until_parked();
2100    panel.update_in(cx, |panel, window, cx| {
2101        assert!(panel.filename_editor.read(cx).is_focused(window));
2102    });
2103    assert_eq!(
2104        visible_entries_as_strings(&panel, 0..10, cx),
2105        &[
2106            "v src",
2107            "    v test",
2108            "          [EDITOR: '']  <== selected",
2109            "          first.rs",
2110            "          second.rs",
2111            "          third.rs"
2112        ]
2113    );
2114    panel.update_in(cx, |panel, window, cx| {
2115        panel
2116            .filename_editor
2117            .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2118        assert!(
2119            panel.confirm_edit(window, cx).is_none(),
2120            "Should not allow to confirm on conflicting new file name"
2121        );
2122    });
2123    cx.executor().run_until_parked();
2124    panel.update_in(cx, |panel, window, cx| {
2125        assert!(
2126            panel.state.edit_state.is_some(),
2127            "Edit state should not be None after conflicting new file name"
2128        );
2129        panel.cancel(&menu::Cancel, window, cx);
2130        panel.update_visible_entries(None, false, false, window, cx);
2131    });
2132    cx.run_until_parked();
2133    assert_eq!(
2134        visible_entries_as_strings(&panel, 0..10, cx),
2135        &[
2136            "v src",
2137            "    v test  <== selected",
2138            "          first.rs",
2139            "          second.rs",
2140            "          third.rs"
2141        ],
2142        "File list should be unchanged after failed file create confirmation"
2143    );
2144
2145    select_path(&panel, "src/test/first.rs", cx);
2146    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2147    cx.executor().run_until_parked();
2148    assert_eq!(
2149        visible_entries_as_strings(&panel, 0..10, cx),
2150        &[
2151            "v src",
2152            "    v test",
2153            "          first.rs  <== selected",
2154            "          second.rs",
2155            "          third.rs"
2156        ],
2157    );
2158    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2159    panel.update_in(cx, |panel, window, cx| {
2160        assert!(panel.filename_editor.read(cx).is_focused(window));
2161    });
2162    assert_eq!(
2163        visible_entries_as_strings(&panel, 0..10, cx),
2164        &[
2165            "v src",
2166            "    v test",
2167            "          [EDITOR: 'first.rs']  <== selected",
2168            "          second.rs",
2169            "          third.rs"
2170        ]
2171    );
2172    panel.update_in(cx, |panel, window, cx| {
2173        panel
2174            .filename_editor
2175            .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2176        assert!(
2177            panel.confirm_edit(window, cx).is_none(),
2178            "Should not allow to confirm on conflicting file rename"
2179        )
2180    });
2181    cx.executor().run_until_parked();
2182    panel.update_in(cx, |panel, window, cx| {
2183        assert!(
2184            panel.state.edit_state.is_some(),
2185            "Edit state should not be None after conflicting file rename"
2186        );
2187        panel.cancel(&menu::Cancel, window, cx);
2188    });
2189    assert_eq!(
2190        visible_entries_as_strings(&panel, 0..10, cx),
2191        &[
2192            "v src",
2193            "    v test",
2194            "          first.rs  <== selected",
2195            "          second.rs",
2196            "          third.rs"
2197        ],
2198        "File list should be unchanged after failed rename confirmation"
2199    );
2200}
2201
2202#[gpui::test]
2203async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
2204    init_test_with_editor(cx);
2205
2206    let fs = FakeFs::new(cx.executor());
2207    fs.insert_tree(
2208        path!("/root"),
2209        json!({
2210            "tree1": {
2211                ".git": {},
2212                "dir1": {
2213                    "modified1.txt": "1",
2214                    "unmodified1.txt": "1",
2215                    "modified2.txt": "1",
2216                },
2217                "dir2": {
2218                    "modified3.txt": "1",
2219                    "unmodified2.txt": "1",
2220                },
2221                "modified4.txt": "1",
2222                "unmodified3.txt": "1",
2223            },
2224            "tree2": {
2225                ".git": {},
2226                "dir3": {
2227                    "modified5.txt": "1",
2228                    "unmodified4.txt": "1",
2229                },
2230                "modified6.txt": "1",
2231                "unmodified5.txt": "1",
2232            }
2233        }),
2234    )
2235    .await;
2236
2237    // Mark files as git modified
2238    fs.set_head_and_index_for_repo(
2239        path!("/root/tree1/.git").as_ref(),
2240        &[
2241            ("dir1/modified1.txt", "modified".into()),
2242            ("dir1/modified2.txt", "modified".into()),
2243            ("modified4.txt", "modified".into()),
2244            ("dir2/modified3.txt", "modified".into()),
2245        ],
2246    );
2247    fs.set_head_and_index_for_repo(
2248        path!("/root/tree2/.git").as_ref(),
2249        &[
2250            ("dir3/modified5.txt", "modified".into()),
2251            ("modified6.txt", "modified".into()),
2252        ],
2253    );
2254
2255    let project = Project::test(
2256        fs.clone(),
2257        [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2258        cx,
2259    )
2260    .await;
2261
2262    let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
2263        let mut worktrees = project.worktrees(cx);
2264        let worktree1 = worktrees.next().unwrap();
2265        let worktree2 = worktrees.next().unwrap();
2266        (
2267            worktree1.read(cx).as_local().unwrap().scan_complete(),
2268            worktree2.read(cx).as_local().unwrap().scan_complete(),
2269        )
2270    });
2271    scan1_complete.await;
2272    scan2_complete.await;
2273    cx.run_until_parked();
2274
2275    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2276    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2277    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2278    cx.run_until_parked();
2279
2280    // Check initial state
2281    assert_eq!(
2282        visible_entries_as_strings(&panel, 0..15, cx),
2283        &[
2284            "v tree1",
2285            "    > .git",
2286            "    > dir1",
2287            "    > dir2",
2288            "      modified4.txt",
2289            "      unmodified3.txt",
2290            "v tree2",
2291            "    > .git",
2292            "    > dir3",
2293            "      modified6.txt",
2294            "      unmodified5.txt"
2295        ],
2296    );
2297
2298    // Test selecting next modified entry
2299    panel.update_in(cx, |panel, window, cx| {
2300        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2301    });
2302    cx.run_until_parked();
2303
2304    assert_eq!(
2305        visible_entries_as_strings(&panel, 0..6, cx),
2306        &[
2307            "v tree1",
2308            "    > .git",
2309            "    v dir1",
2310            "          modified1.txt  <== selected",
2311            "          modified2.txt",
2312            "          unmodified1.txt",
2313        ],
2314    );
2315
2316    panel.update_in(cx, |panel, window, cx| {
2317        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2318    });
2319    cx.run_until_parked();
2320
2321    assert_eq!(
2322        visible_entries_as_strings(&panel, 0..6, cx),
2323        &[
2324            "v tree1",
2325            "    > .git",
2326            "    v dir1",
2327            "          modified1.txt",
2328            "          modified2.txt  <== selected",
2329            "          unmodified1.txt",
2330        ],
2331    );
2332
2333    panel.update_in(cx, |panel, window, cx| {
2334        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2335    });
2336    cx.run_until_parked();
2337
2338    assert_eq!(
2339        visible_entries_as_strings(&panel, 6..9, cx),
2340        &[
2341            "    v dir2",
2342            "          modified3.txt  <== selected",
2343            "          unmodified2.txt",
2344        ],
2345    );
2346
2347    panel.update_in(cx, |panel, window, cx| {
2348        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2349    });
2350    cx.run_until_parked();
2351
2352    assert_eq!(
2353        visible_entries_as_strings(&panel, 9..11, cx),
2354        &["      modified4.txt  <== selected", "      unmodified3.txt",],
2355    );
2356
2357    panel.update_in(cx, |panel, window, cx| {
2358        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2359    });
2360    cx.run_until_parked();
2361
2362    assert_eq!(
2363        visible_entries_as_strings(&panel, 13..16, cx),
2364        &[
2365            "    v dir3",
2366            "          modified5.txt  <== selected",
2367            "          unmodified4.txt",
2368        ],
2369    );
2370
2371    panel.update_in(cx, |panel, window, cx| {
2372        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2373    });
2374    cx.run_until_parked();
2375
2376    assert_eq!(
2377        visible_entries_as_strings(&panel, 16..18, cx),
2378        &["      modified6.txt  <== selected", "      unmodified5.txt",],
2379    );
2380
2381    // Wraps around to first modified file
2382    panel.update_in(cx, |panel, window, cx| {
2383        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2384    });
2385    cx.run_until_parked();
2386
2387    assert_eq!(
2388        visible_entries_as_strings(&panel, 0..18, cx),
2389        &[
2390            "v tree1",
2391            "    > .git",
2392            "    v dir1",
2393            "          modified1.txt  <== selected",
2394            "          modified2.txt",
2395            "          unmodified1.txt",
2396            "    v dir2",
2397            "          modified3.txt",
2398            "          unmodified2.txt",
2399            "      modified4.txt",
2400            "      unmodified3.txt",
2401            "v tree2",
2402            "    > .git",
2403            "    v dir3",
2404            "          modified5.txt",
2405            "          unmodified4.txt",
2406            "      modified6.txt",
2407            "      unmodified5.txt",
2408        ],
2409    );
2410
2411    // Wraps around again to last modified file
2412    panel.update_in(cx, |panel, window, cx| {
2413        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2414    });
2415    cx.run_until_parked();
2416
2417    assert_eq!(
2418        visible_entries_as_strings(&panel, 16..18, cx),
2419        &["      modified6.txt  <== selected", "      unmodified5.txt",],
2420    );
2421
2422    panel.update_in(cx, |panel, window, cx| {
2423        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2424    });
2425    cx.run_until_parked();
2426
2427    assert_eq!(
2428        visible_entries_as_strings(&panel, 13..16, cx),
2429        &[
2430            "    v dir3",
2431            "          modified5.txt  <== selected",
2432            "          unmodified4.txt",
2433        ],
2434    );
2435
2436    panel.update_in(cx, |panel, window, cx| {
2437        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2438    });
2439    cx.run_until_parked();
2440
2441    assert_eq!(
2442        visible_entries_as_strings(&panel, 9..11, cx),
2443        &["      modified4.txt  <== selected", "      unmodified3.txt",],
2444    );
2445
2446    panel.update_in(cx, |panel, window, cx| {
2447        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2448    });
2449    cx.run_until_parked();
2450
2451    assert_eq!(
2452        visible_entries_as_strings(&panel, 6..9, cx),
2453        &[
2454            "    v dir2",
2455            "          modified3.txt  <== selected",
2456            "          unmodified2.txt",
2457        ],
2458    );
2459
2460    panel.update_in(cx, |panel, window, cx| {
2461        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2462    });
2463    cx.run_until_parked();
2464
2465    assert_eq!(
2466        visible_entries_as_strings(&panel, 0..6, cx),
2467        &[
2468            "v tree1",
2469            "    > .git",
2470            "    v dir1",
2471            "          modified1.txt",
2472            "          modified2.txt  <== selected",
2473            "          unmodified1.txt",
2474        ],
2475    );
2476
2477    panel.update_in(cx, |panel, window, cx| {
2478        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2479    });
2480    cx.run_until_parked();
2481
2482    assert_eq!(
2483        visible_entries_as_strings(&panel, 0..6, cx),
2484        &[
2485            "v tree1",
2486            "    > .git",
2487            "    v dir1",
2488            "          modified1.txt  <== selected",
2489            "          modified2.txt",
2490            "          unmodified1.txt",
2491        ],
2492    );
2493}
2494
2495#[gpui::test]
2496async fn test_select_directory(cx: &mut gpui::TestAppContext) {
2497    init_test_with_editor(cx);
2498
2499    let fs = FakeFs::new(cx.executor());
2500    fs.insert_tree(
2501        "/project_root",
2502        json!({
2503            "dir_1": {
2504                "nested_dir": {
2505                    "file_a.py": "# File contents",
2506                }
2507            },
2508            "file_1.py": "# File contents",
2509            "dir_2": {
2510
2511            },
2512            "dir_3": {
2513
2514            },
2515            "file_2.py": "# File contents",
2516            "dir_4": {
2517
2518            },
2519        }),
2520    )
2521    .await;
2522
2523    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2524    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2525    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2526    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2527    cx.run_until_parked();
2528
2529    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2530    cx.executor().run_until_parked();
2531    select_path(&panel, "project_root/dir_1", cx);
2532    cx.executor().run_until_parked();
2533    assert_eq!(
2534        visible_entries_as_strings(&panel, 0..10, cx),
2535        &[
2536            "v project_root",
2537            "    > dir_1  <== selected",
2538            "    > dir_2",
2539            "    > dir_3",
2540            "    > dir_4",
2541            "      file_1.py",
2542            "      file_2.py",
2543        ]
2544    );
2545    panel.update_in(cx, |panel, window, cx| {
2546        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2547    });
2548
2549    assert_eq!(
2550        visible_entries_as_strings(&panel, 0..10, cx),
2551        &[
2552            "v project_root  <== selected",
2553            "    > dir_1",
2554            "    > dir_2",
2555            "    > dir_3",
2556            "    > dir_4",
2557            "      file_1.py",
2558            "      file_2.py",
2559        ]
2560    );
2561
2562    panel.update_in(cx, |panel, window, cx| {
2563        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2564    });
2565
2566    assert_eq!(
2567        visible_entries_as_strings(&panel, 0..10, cx),
2568        &[
2569            "v project_root",
2570            "    > dir_1",
2571            "    > dir_2",
2572            "    > dir_3",
2573            "    > dir_4  <== selected",
2574            "      file_1.py",
2575            "      file_2.py",
2576        ]
2577    );
2578
2579    panel.update_in(cx, |panel, window, cx| {
2580        panel.select_next_directory(&SelectNextDirectory, window, cx)
2581    });
2582
2583    assert_eq!(
2584        visible_entries_as_strings(&panel, 0..10, cx),
2585        &[
2586            "v project_root  <== selected",
2587            "    > dir_1",
2588            "    > dir_2",
2589            "    > dir_3",
2590            "    > dir_4",
2591            "      file_1.py",
2592            "      file_2.py",
2593        ]
2594    );
2595}
2596
2597#[gpui::test]
2598async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
2599    init_test_with_editor(cx);
2600
2601    let fs = FakeFs::new(cx.executor());
2602    fs.insert_tree(
2603        "/project_root",
2604        json!({
2605            "dir_1": {
2606                "nested_dir": {
2607                    "file_a.py": "# File contents",
2608                }
2609            },
2610            "file_1.py": "# File contents",
2611            "file_2.py": "# File contents",
2612            "zdir_2": {
2613                "nested_dir2": {
2614                    "file_b.py": "# File contents",
2615                }
2616            },
2617        }),
2618    )
2619    .await;
2620
2621    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2622    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2623    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2624    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2625    cx.run_until_parked();
2626
2627    assert_eq!(
2628        visible_entries_as_strings(&panel, 0..10, cx),
2629        &[
2630            "v project_root",
2631            "    > dir_1",
2632            "    > zdir_2",
2633            "      file_1.py",
2634            "      file_2.py",
2635        ]
2636    );
2637    panel.update_in(cx, |panel, window, cx| {
2638        panel.select_first(&SelectFirst, window, cx)
2639    });
2640
2641    assert_eq!(
2642        visible_entries_as_strings(&panel, 0..10, cx),
2643        &[
2644            "v project_root  <== selected",
2645            "    > dir_1",
2646            "    > zdir_2",
2647            "      file_1.py",
2648            "      file_2.py",
2649        ]
2650    );
2651
2652    panel.update_in(cx, |panel, window, cx| {
2653        panel.select_last(&SelectLast, window, cx)
2654    });
2655
2656    assert_eq!(
2657        visible_entries_as_strings(&panel, 0..10, cx),
2658        &[
2659            "v project_root",
2660            "    > dir_1",
2661            "    > zdir_2",
2662            "      file_1.py",
2663            "      file_2.py  <== selected",
2664        ]
2665    );
2666
2667    cx.update(|_, cx| {
2668        let settings = *ProjectPanelSettings::get_global(cx);
2669        ProjectPanelSettings::override_global(
2670            ProjectPanelSettings {
2671                hide_root: true,
2672                ..settings
2673            },
2674            cx,
2675        );
2676    });
2677
2678    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2679    cx.run_until_parked();
2680
2681    #[rustfmt::skip]
2682    assert_eq!(
2683        visible_entries_as_strings(&panel, 0..10, cx),
2684        &[
2685            "> dir_1",
2686            "> zdir_2",
2687            "  file_1.py",
2688            "  file_2.py",
2689        ],
2690        "With hide_root=true, root should be hidden"
2691    );
2692
2693    panel.update_in(cx, |panel, window, cx| {
2694        panel.select_first(&SelectFirst, window, cx)
2695    });
2696
2697    assert_eq!(
2698        visible_entries_as_strings(&panel, 0..10, cx),
2699        &[
2700            "> dir_1  <== selected",
2701            "> zdir_2",
2702            "  file_1.py",
2703            "  file_2.py",
2704        ],
2705        "With hide_root=true, first entry should be dir_1, not the hidden root"
2706    );
2707}
2708
2709#[gpui::test]
2710async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2711    init_test_with_editor(cx);
2712
2713    let fs = FakeFs::new(cx.executor());
2714    fs.insert_tree(
2715        "/project_root",
2716        json!({
2717            "dir_1": {
2718                "nested_dir": {
2719                    "file_a.py": "# File contents",
2720                }
2721            },
2722            "file_1.py": "# File contents",
2723        }),
2724    )
2725    .await;
2726
2727    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2728    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2729    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2730    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2731    cx.run_until_parked();
2732
2733    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2734    cx.executor().run_until_parked();
2735    select_path(&panel, "project_root/dir_1", cx);
2736    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2737    select_path(&panel, "project_root/dir_1/nested_dir", cx);
2738    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2739    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2740    cx.executor().run_until_parked();
2741    assert_eq!(
2742        visible_entries_as_strings(&panel, 0..10, cx),
2743        &[
2744            "v project_root",
2745            "    v dir_1",
2746            "        > nested_dir  <== selected",
2747            "      file_1.py",
2748        ]
2749    );
2750}
2751
2752#[gpui::test]
2753async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2754    init_test_with_editor(cx);
2755
2756    let fs = FakeFs::new(cx.executor());
2757    fs.insert_tree(
2758        "/project_root",
2759        json!({
2760            "dir_1": {
2761                "nested_dir": {
2762                    "file_a.py": "# File contents",
2763                    "file_b.py": "# File contents",
2764                    "file_c.py": "# File contents",
2765                },
2766                "file_1.py": "# File contents",
2767                "file_2.py": "# File contents",
2768                "file_3.py": "# File contents",
2769            },
2770            "dir_2": {
2771                "file_1.py": "# File contents",
2772                "file_2.py": "# File contents",
2773                "file_3.py": "# File contents",
2774            }
2775        }),
2776    )
2777    .await;
2778
2779    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2780    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2781    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2782    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2783    cx.run_until_parked();
2784
2785    panel.update_in(cx, |panel, window, cx| {
2786        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2787    });
2788    cx.executor().run_until_parked();
2789    assert_eq!(
2790        visible_entries_as_strings(&panel, 0..10, cx),
2791        &["v project_root", "    > dir_1", "    > dir_2",]
2792    );
2793
2794    // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2795    toggle_expand_dir(&panel, "project_root/dir_1", cx);
2796    cx.executor().run_until_parked();
2797    assert_eq!(
2798        visible_entries_as_strings(&panel, 0..10, cx),
2799        &[
2800            "v project_root",
2801            "    v dir_1  <== selected",
2802            "        > nested_dir",
2803            "          file_1.py",
2804            "          file_2.py",
2805            "          file_3.py",
2806            "    > dir_2",
2807        ]
2808    );
2809}
2810
2811#[gpui::test]
2812async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
2813    init_test_with_editor(cx);
2814
2815    let fs = FakeFs::new(cx.executor());
2816    let worktree_content = json!({
2817        "dir_1": {
2818            "file_1.py": "# File contents",
2819        },
2820        "dir_2": {
2821            "file_1.py": "# File contents",
2822        }
2823    });
2824
2825    fs.insert_tree("/project_root_1", worktree_content.clone())
2826        .await;
2827    fs.insert_tree("/project_root_2", worktree_content).await;
2828
2829    let project = Project::test(
2830        fs.clone(),
2831        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
2832        cx,
2833    )
2834    .await;
2835    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2836    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2837    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2838    cx.run_until_parked();
2839
2840    panel.update_in(cx, |panel, window, cx| {
2841        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2842    });
2843    cx.executor().run_until_parked();
2844    assert_eq!(
2845        visible_entries_as_strings(&panel, 0..10, cx),
2846        &["> project_root_1", "> project_root_2",]
2847    );
2848}
2849
2850#[gpui::test]
2851async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
2852    init_test_with_editor(cx);
2853
2854    let fs = FakeFs::new(cx.executor());
2855    fs.insert_tree(
2856        "/project_root",
2857        json!({
2858            "dir_1": {
2859                "nested_dir": {
2860                    "file_a.py": "# File contents",
2861                    "file_b.py": "# File contents",
2862                    "file_c.py": "# File contents",
2863                },
2864                "file_1.py": "# File contents",
2865                "file_2.py": "# File contents",
2866                "file_3.py": "# File contents",
2867            },
2868            "dir_2": {
2869                "file_1.py": "# File contents",
2870                "file_2.py": "# File contents",
2871                "file_3.py": "# File contents",
2872            }
2873        }),
2874    )
2875    .await;
2876
2877    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2878    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2879    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2880    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2881    cx.run_until_parked();
2882
2883    // Open project_root/dir_1 to ensure that a nested directory is expanded
2884    toggle_expand_dir(&panel, "project_root/dir_1", cx);
2885    cx.executor().run_until_parked();
2886    assert_eq!(
2887        visible_entries_as_strings(&panel, 0..10, cx),
2888        &[
2889            "v project_root",
2890            "    v dir_1  <== selected",
2891            "        > nested_dir",
2892            "          file_1.py",
2893            "          file_2.py",
2894            "          file_3.py",
2895            "    > dir_2",
2896        ]
2897    );
2898
2899    // Close root directory
2900    toggle_expand_dir(&panel, "project_root", cx);
2901    cx.executor().run_until_parked();
2902    assert_eq!(
2903        visible_entries_as_strings(&panel, 0..10, cx),
2904        &["> project_root  <== selected"]
2905    );
2906
2907    // Run collapse_all_entries and make sure root is not expanded
2908    panel.update_in(cx, |panel, window, cx| {
2909        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2910    });
2911    cx.executor().run_until_parked();
2912    assert_eq!(
2913        visible_entries_as_strings(&panel, 0..10, cx),
2914        &["> project_root  <== selected"]
2915    );
2916}
2917
2918#[gpui::test]
2919async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2920    init_test(cx);
2921
2922    let fs = FakeFs::new(cx.executor());
2923    fs.as_fake().insert_tree(path!("/root"), json!({})).await;
2924    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
2925    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2926    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2927    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2928    cx.run_until_parked();
2929
2930    // Make a new buffer with no backing file
2931    workspace
2932        .update(cx, |workspace, window, cx| {
2933            Editor::new_file(workspace, &Default::default(), window, cx)
2934        })
2935        .unwrap();
2936
2937    cx.executor().run_until_parked();
2938
2939    // "Save as" the buffer, creating a new backing file for it
2940    let save_task = workspace
2941        .update(cx, |workspace, window, cx| {
2942            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2943        })
2944        .unwrap();
2945
2946    cx.executor().run_until_parked();
2947    cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
2948    save_task.await.unwrap();
2949
2950    // Rename the file
2951    select_path(&panel, "root/new", cx);
2952    assert_eq!(
2953        visible_entries_as_strings(&panel, 0..10, cx),
2954        &["v root", "      new  <== selected  <== marked"]
2955    );
2956    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2957    panel.update_in(cx, |panel, window, cx| {
2958        panel
2959            .filename_editor
2960            .update(cx, |editor, cx| editor.set_text("newer", window, cx));
2961    });
2962    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2963
2964    cx.executor().run_until_parked();
2965    assert_eq!(
2966        visible_entries_as_strings(&panel, 0..10, cx),
2967        &["v root", "      newer  <== selected"]
2968    );
2969
2970    workspace
2971        .update(cx, |workspace, window, cx| {
2972            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2973        })
2974        .unwrap()
2975        .await
2976        .unwrap();
2977
2978    cx.executor().run_until_parked();
2979    // assert that saving the file doesn't restore "new"
2980    assert_eq!(
2981        visible_entries_as_strings(&panel, 0..10, cx),
2982        &["v root", "      newer  <== selected"]
2983    );
2984}
2985
2986// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
2987// you can't rename a directory which some program has already open. This is a
2988// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
2989// to it, and thus renaming it will fail on Windows.
2990// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
2991// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
2992#[gpui::test]
2993#[cfg_attr(target_os = "windows", ignore)]
2994async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2995    init_test_with_editor(cx);
2996
2997    let fs = FakeFs::new(cx.executor());
2998    fs.insert_tree(
2999        "/root1",
3000        json!({
3001            "dir1": {
3002                "file1.txt": "content 1",
3003            },
3004        }),
3005    )
3006    .await;
3007
3008    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3009    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3010    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3011    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3012    cx.run_until_parked();
3013
3014    toggle_expand_dir(&panel, "root1/dir1", cx);
3015
3016    assert_eq!(
3017        visible_entries_as_strings(&panel, 0..20, cx),
3018        &["v root1", "    v dir1  <== selected", "          file1.txt",],
3019        "Initial state with worktrees"
3020    );
3021
3022    select_path(&panel, "root1", cx);
3023    assert_eq!(
3024        visible_entries_as_strings(&panel, 0..20, cx),
3025        &["v root1  <== selected", "    v dir1", "          file1.txt",],
3026    );
3027
3028    // Rename root1 to new_root1
3029    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3030
3031    assert_eq!(
3032        visible_entries_as_strings(&panel, 0..20, cx),
3033        &[
3034            "v [EDITOR: 'root1']  <== selected",
3035            "    v dir1",
3036            "          file1.txt",
3037        ],
3038    );
3039
3040    let confirm = panel.update_in(cx, |panel, window, cx| {
3041        panel
3042            .filename_editor
3043            .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3044        panel.confirm_edit(window, cx).unwrap()
3045    });
3046    confirm.await.unwrap();
3047    cx.run_until_parked();
3048    assert_eq!(
3049        visible_entries_as_strings(&panel, 0..20, cx),
3050        &[
3051            "v new_root1  <== selected",
3052            "    v dir1",
3053            "          file1.txt",
3054        ],
3055        "Should update worktree name"
3056    );
3057
3058    // Ensure internal paths have been updated
3059    select_path(&panel, "new_root1/dir1/file1.txt", cx);
3060    assert_eq!(
3061        visible_entries_as_strings(&panel, 0..20, cx),
3062        &[
3063            "v new_root1",
3064            "    v dir1",
3065            "          file1.txt  <== selected",
3066        ],
3067        "Files in renamed worktree are selectable"
3068    );
3069}
3070
3071#[gpui::test]
3072async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3073    init_test_with_editor(cx);
3074
3075    let fs = FakeFs::new(cx.executor());
3076    fs.insert_tree(
3077        "/root1",
3078        json!({
3079            "dir1": { "file1.txt": "content" },
3080            "file2.txt": "content",
3081        }),
3082    )
3083    .await;
3084    fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3085        .await;
3086
3087    // Test 1: Single worktree, hide_root=true - rename should be blocked
3088    {
3089        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3090        let workspace =
3091            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3092        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3093
3094        cx.update(|_, cx| {
3095            let settings = *ProjectPanelSettings::get_global(cx);
3096            ProjectPanelSettings::override_global(
3097                ProjectPanelSettings {
3098                    hide_root: true,
3099                    ..settings
3100                },
3101                cx,
3102            );
3103        });
3104
3105        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3106        cx.run_until_parked();
3107
3108        panel.update(cx, |panel, cx| {
3109            let project = panel.project.read(cx);
3110            let worktree = project.visible_worktrees(cx).next().unwrap();
3111            let root_entry = worktree.read(cx).root_entry().unwrap();
3112            panel.state.selection = Some(SelectedEntry {
3113                worktree_id: worktree.read(cx).id(),
3114                entry_id: root_entry.id,
3115            });
3116        });
3117
3118        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3119
3120        assert!(
3121            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3122            "Rename should be blocked when hide_root=true with single worktree"
3123        );
3124    }
3125
3126    // Test 2: Multiple worktrees, hide_root=true - rename should work
3127    {
3128        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3129        let workspace =
3130            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3131        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3132
3133        cx.update(|_, cx| {
3134            let settings = *ProjectPanelSettings::get_global(cx);
3135            ProjectPanelSettings::override_global(
3136                ProjectPanelSettings {
3137                    hide_root: true,
3138                    ..settings
3139                },
3140                cx,
3141            );
3142        });
3143
3144        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3145        cx.run_until_parked();
3146
3147        select_path(&panel, "root1", cx);
3148        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3149
3150        #[cfg(target_os = "windows")]
3151        assert!(
3152            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3153            "Rename should be blocked on Windows even with multiple worktrees"
3154        );
3155
3156        #[cfg(not(target_os = "windows"))]
3157        {
3158            assert!(
3159                panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
3160                "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3161            );
3162            panel.update_in(cx, |panel, window, cx| {
3163                panel.cancel(&menu::Cancel, window, cx)
3164            });
3165        }
3166    }
3167}
3168
3169#[gpui::test]
3170async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3171    init_test_with_editor(cx);
3172    let fs = FakeFs::new(cx.executor());
3173    fs.insert_tree(
3174        "/project_root",
3175        json!({
3176            "dir_1": {
3177                "nested_dir": {
3178                    "file_a.py": "# File contents",
3179                }
3180            },
3181            "file_1.py": "# File contents",
3182        }),
3183    )
3184    .await;
3185
3186    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3187    let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3188    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3189    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3190    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3191    cx.run_until_parked();
3192
3193    cx.update(|window, cx| {
3194        panel.update(cx, |this, cx| {
3195            this.select_next(&Default::default(), window, cx);
3196            this.expand_selected_entry(&Default::default(), window, cx);
3197        })
3198    });
3199    cx.run_until_parked();
3200
3201    cx.update(|window, cx| {
3202        panel.update(cx, |this, cx| {
3203            this.expand_selected_entry(&Default::default(), window, cx);
3204        })
3205    });
3206    cx.run_until_parked();
3207
3208    cx.update(|window, cx| {
3209        panel.update(cx, |this, cx| {
3210            this.select_next(&Default::default(), window, cx);
3211            this.expand_selected_entry(&Default::default(), window, cx);
3212        })
3213    });
3214    cx.run_until_parked();
3215
3216    cx.update(|window, cx| {
3217        panel.update(cx, |this, cx| {
3218            this.select_next(&Default::default(), window, cx);
3219        })
3220    });
3221    cx.run_until_parked();
3222
3223    assert_eq!(
3224        visible_entries_as_strings(&panel, 0..10, cx),
3225        &[
3226            "v project_root",
3227            "    v dir_1",
3228            "        v nested_dir",
3229            "              file_a.py  <== selected",
3230            "      file_1.py",
3231        ]
3232    );
3233    let modifiers_with_shift = gpui::Modifiers {
3234        shift: true,
3235        ..Default::default()
3236    };
3237    cx.run_until_parked();
3238    cx.simulate_modifiers_change(modifiers_with_shift);
3239    cx.update(|window, cx| {
3240        panel.update(cx, |this, cx| {
3241            this.select_next(&Default::default(), window, cx);
3242        })
3243    });
3244    assert_eq!(
3245        visible_entries_as_strings(&panel, 0..10, cx),
3246        &[
3247            "v project_root",
3248            "    v dir_1",
3249            "        v nested_dir",
3250            "              file_a.py",
3251            "      file_1.py  <== selected  <== marked",
3252        ]
3253    );
3254    cx.update(|window, cx| {
3255        panel.update(cx, |this, cx| {
3256            this.select_previous(&Default::default(), window, cx);
3257        })
3258    });
3259    assert_eq!(
3260        visible_entries_as_strings(&panel, 0..10, cx),
3261        &[
3262            "v project_root",
3263            "    v dir_1",
3264            "        v nested_dir",
3265            "              file_a.py  <== selected  <== marked",
3266            "      file_1.py  <== marked",
3267        ]
3268    );
3269    cx.update(|window, cx| {
3270        panel.update(cx, |this, cx| {
3271            let drag = DraggedSelection {
3272                active_selection: this.state.selection.unwrap(),
3273                marked_selections: this.marked_entries.clone().into(),
3274            };
3275            let target_entry = this
3276                .project
3277                .read(cx)
3278                .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
3279                .unwrap();
3280            this.drag_onto(&drag, target_entry.id, false, window, cx);
3281        });
3282    });
3283    cx.run_until_parked();
3284    assert_eq!(
3285        visible_entries_as_strings(&panel, 0..10, cx),
3286        &[
3287            "v project_root",
3288            "    v dir_1",
3289            "        v nested_dir",
3290            "      file_1.py  <== marked",
3291            "      file_a.py  <== selected  <== marked",
3292        ]
3293    );
3294    // ESC clears out all marks
3295    cx.update(|window, cx| {
3296        panel.update(cx, |this, cx| {
3297            this.cancel(&menu::Cancel, window, cx);
3298        })
3299    });
3300    assert_eq!(
3301        visible_entries_as_strings(&panel, 0..10, cx),
3302        &[
3303            "v project_root",
3304            "    v dir_1",
3305            "        v nested_dir",
3306            "      file_1.py",
3307            "      file_a.py  <== selected",
3308        ]
3309    );
3310    // ESC clears out all marks
3311    cx.update(|window, cx| {
3312        panel.update(cx, |this, cx| {
3313            this.select_previous(&SelectPrevious, window, cx);
3314            this.select_next(&SelectNext, window, cx);
3315        })
3316    });
3317    assert_eq!(
3318        visible_entries_as_strings(&panel, 0..10, cx),
3319        &[
3320            "v project_root",
3321            "    v dir_1",
3322            "        v nested_dir",
3323            "      file_1.py  <== marked",
3324            "      file_a.py  <== selected  <== marked",
3325        ]
3326    );
3327    cx.simulate_modifiers_change(Default::default());
3328    cx.update(|window, cx| {
3329        panel.update(cx, |this, cx| {
3330            this.cut(&Cut, window, cx);
3331            this.select_previous(&SelectPrevious, window, cx);
3332            this.select_previous(&SelectPrevious, window, cx);
3333
3334            this.paste(&Paste, window, cx);
3335            this.update_visible_entries(None, false, false, window, cx);
3336        })
3337    });
3338    cx.run_until_parked();
3339    assert_eq!(
3340        visible_entries_as_strings(&panel, 0..10, cx),
3341        &[
3342            "v project_root",
3343            "    v dir_1",
3344            "        v nested_dir",
3345            "              file_1.py  <== marked",
3346            "              file_a.py  <== selected  <== marked",
3347        ]
3348    );
3349    cx.simulate_modifiers_change(modifiers_with_shift);
3350    cx.update(|window, cx| {
3351        panel.update(cx, |this, cx| {
3352            this.expand_selected_entry(&Default::default(), window, cx);
3353            this.select_next(&SelectNext, window, cx);
3354            this.select_next(&SelectNext, window, cx);
3355        })
3356    });
3357    submit_deletion(&panel, cx);
3358    assert_eq!(
3359        visible_entries_as_strings(&panel, 0..10, cx),
3360        &[
3361            "v project_root",
3362            "    v dir_1",
3363            "        v nested_dir  <== selected",
3364        ]
3365    );
3366}
3367
3368#[gpui::test]
3369async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
3370    init_test(cx);
3371
3372    let fs = FakeFs::new(cx.executor());
3373    fs.insert_tree(
3374        "/root",
3375        json!({
3376            "a": {
3377                "b": {
3378                    "c": {
3379                        "d": {}
3380                    }
3381                }
3382            },
3383            "target_destination": {}
3384        }),
3385    )
3386    .await;
3387
3388    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3389    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3390    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3391
3392    cx.update(|_, cx| {
3393        let settings = *ProjectPanelSettings::get_global(cx);
3394        ProjectPanelSettings::override_global(
3395            ProjectPanelSettings {
3396                auto_fold_dirs: true,
3397                ..settings
3398            },
3399            cx,
3400        );
3401    });
3402
3403    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3404    cx.run_until_parked();
3405
3406    // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
3407    select_path(&panel, "root/a/b/c/d", cx);
3408    panel.update_in(cx, |panel, window, cx| {
3409        let drag = DraggedSelection {
3410            active_selection: SelectedEntry {
3411                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3412                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3413            },
3414            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3415        };
3416        let target_entry = panel
3417            .project
3418            .read(cx)
3419            .visible_worktrees(cx)
3420            .next()
3421            .unwrap()
3422            .read(cx)
3423            .entry_for_path(rel_path("target_destination"))
3424            .unwrap();
3425        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3426    });
3427    cx.executor().run_until_parked();
3428
3429    assert_eq!(
3430        visible_entries_as_strings(&panel, 0..10, cx),
3431        &[
3432            "v root",
3433            "    > a/b/c",
3434            "    > target_destination/d  <== selected"
3435        ],
3436        "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
3437    );
3438
3439    // Reset
3440    select_path(&panel, "root/target_destination/d", cx);
3441    panel.update_in(cx, |panel, window, cx| {
3442        let drag = DraggedSelection {
3443            active_selection: SelectedEntry {
3444                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3445                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3446            },
3447            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3448        };
3449        let target_entry = panel
3450            .project
3451            .read(cx)
3452            .visible_worktrees(cx)
3453            .next()
3454            .unwrap()
3455            .read(cx)
3456            .entry_for_path(rel_path("a/b/c"))
3457            .unwrap();
3458        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3459    });
3460    cx.executor().run_until_parked();
3461
3462    // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
3463    select_path(&panel, "root/a/b", cx);
3464    panel.update_in(cx, |panel, window, cx| {
3465        let drag = DraggedSelection {
3466            active_selection: SelectedEntry {
3467                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3468                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3469            },
3470            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3471        };
3472        let target_entry = panel
3473            .project
3474            .read(cx)
3475            .visible_worktrees(cx)
3476            .next()
3477            .unwrap()
3478            .read(cx)
3479            .entry_for_path(rel_path("target_destination"))
3480            .unwrap();
3481        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3482    });
3483    cx.executor().run_until_parked();
3484
3485    assert_eq!(
3486        visible_entries_as_strings(&panel, 0..10, cx),
3487        &["v root", "    v a", "    > target_destination/b/c/d"],
3488        "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
3489    );
3490
3491    // Reset
3492    select_path(&panel, "root/target_destination/b", cx);
3493    panel.update_in(cx, |panel, window, cx| {
3494        let drag = DraggedSelection {
3495            active_selection: SelectedEntry {
3496                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3497                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3498            },
3499            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3500        };
3501        let target_entry = panel
3502            .project
3503            .read(cx)
3504            .visible_worktrees(cx)
3505            .next()
3506            .unwrap()
3507            .read(cx)
3508            .entry_for_path(rel_path("a"))
3509            .unwrap();
3510        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3511    });
3512    cx.executor().run_until_parked();
3513
3514    // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
3515    select_path(&panel, "root/a", cx);
3516    panel.update_in(cx, |panel, window, cx| {
3517        let drag = DraggedSelection {
3518            active_selection: SelectedEntry {
3519                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3520                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3521            },
3522            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3523        };
3524        let target_entry = panel
3525            .project
3526            .read(cx)
3527            .visible_worktrees(cx)
3528            .next()
3529            .unwrap()
3530            .read(cx)
3531            .entry_for_path(rel_path("target_destination"))
3532            .unwrap();
3533        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3534    });
3535    cx.executor().run_until_parked();
3536
3537    assert_eq!(
3538        visible_entries_as_strings(&panel, 0..10, cx),
3539        &["v root", "    > target_destination/a/b/c/d"],
3540        "Moving first directory 'a' should move whole 'a/b/c/d' chain"
3541    );
3542}
3543
3544#[gpui::test]
3545async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3546    init_test_with_editor(cx);
3547    cx.update(|cx| {
3548        cx.update_global::<SettingsStore, _>(|store, cx| {
3549            store.update_user_settings(cx, |settings| {
3550                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3551                settings
3552                    .project_panel
3553                    .get_or_insert_default()
3554                    .auto_reveal_entries = Some(false);
3555            });
3556        })
3557    });
3558
3559    let fs = FakeFs::new(cx.background_executor.clone());
3560    fs.insert_tree(
3561        "/project_root",
3562        json!({
3563            ".git": {},
3564            ".gitignore": "**/gitignored_dir",
3565            "dir_1": {
3566                "file_1.py": "# File 1_1 contents",
3567                "file_2.py": "# File 1_2 contents",
3568                "file_3.py": "# File 1_3 contents",
3569                "gitignored_dir": {
3570                    "file_a.py": "# File contents",
3571                    "file_b.py": "# File contents",
3572                    "file_c.py": "# File contents",
3573                },
3574            },
3575            "dir_2": {
3576                "file_1.py": "# File 2_1 contents",
3577                "file_2.py": "# File 2_2 contents",
3578                "file_3.py": "# File 2_3 contents",
3579            }
3580        }),
3581    )
3582    .await;
3583
3584    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3585    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3586    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3587    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3588    cx.run_until_parked();
3589
3590    assert_eq!(
3591        visible_entries_as_strings(&panel, 0..20, cx),
3592        &[
3593            "v project_root",
3594            "    > .git",
3595            "    > dir_1",
3596            "    > dir_2",
3597            "      .gitignore",
3598        ]
3599    );
3600
3601    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3602        .expect("dir 1 file is not ignored and should have an entry");
3603    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3604        .expect("dir 2 file is not ignored and should have an entry");
3605    let gitignored_dir_file =
3606        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3607    assert_eq!(
3608        gitignored_dir_file, None,
3609        "File in the gitignored dir should not have an entry before its dir is toggled"
3610    );
3611
3612    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3613    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3614    cx.executor().run_until_parked();
3615    assert_eq!(
3616        visible_entries_as_strings(&panel, 0..20, cx),
3617        &[
3618            "v project_root",
3619            "    > .git",
3620            "    v dir_1",
3621            "        v gitignored_dir  <== selected",
3622            "              file_a.py",
3623            "              file_b.py",
3624            "              file_c.py",
3625            "          file_1.py",
3626            "          file_2.py",
3627            "          file_3.py",
3628            "    > dir_2",
3629            "      .gitignore",
3630        ],
3631        "Should show gitignored dir file list in the project panel"
3632    );
3633    let gitignored_dir_file =
3634        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3635            .expect("after gitignored dir got opened, a file entry should be present");
3636
3637    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3638    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3639    assert_eq!(
3640        visible_entries_as_strings(&panel, 0..20, cx),
3641        &[
3642            "v project_root",
3643            "    > .git",
3644            "    > dir_1  <== selected",
3645            "    > dir_2",
3646            "      .gitignore",
3647        ],
3648        "Should hide all dir contents again and prepare for the auto reveal test"
3649    );
3650
3651    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3652        panel.update(cx, |panel, cx| {
3653            panel.project.update(cx, |_, cx| {
3654                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3655            })
3656        });
3657        cx.run_until_parked();
3658        assert_eq!(
3659            visible_entries_as_strings(&panel, 0..20, cx),
3660            &[
3661                "v project_root",
3662                "    > .git",
3663                "    > dir_1  <== selected",
3664                "    > dir_2",
3665                "      .gitignore",
3666            ],
3667            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3668        );
3669    }
3670
3671    cx.update(|_, cx| {
3672        cx.update_global::<SettingsStore, _>(|store, cx| {
3673            store.update_user_settings(cx, |settings| {
3674                settings
3675                    .project_panel
3676                    .get_or_insert_default()
3677                    .auto_reveal_entries = Some(true)
3678            });
3679        })
3680    });
3681
3682    panel.update(cx, |panel, cx| {
3683        panel.project.update(cx, |_, cx| {
3684            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3685        })
3686    });
3687    cx.run_until_parked();
3688    assert_eq!(
3689        visible_entries_as_strings(&panel, 0..20, cx),
3690        &[
3691            "v project_root",
3692            "    > .git",
3693            "    v dir_1",
3694            "        > gitignored_dir",
3695            "          file_1.py  <== selected  <== marked",
3696            "          file_2.py",
3697            "          file_3.py",
3698            "    > dir_2",
3699            "      .gitignore",
3700        ],
3701        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3702    );
3703
3704    panel.update(cx, |panel, cx| {
3705        panel.project.update(cx, |_, cx| {
3706            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3707        })
3708    });
3709    cx.run_until_parked();
3710    assert_eq!(
3711        visible_entries_as_strings(&panel, 0..20, cx),
3712        &[
3713            "v project_root",
3714            "    > .git",
3715            "    v dir_1",
3716            "        > gitignored_dir",
3717            "          file_1.py",
3718            "          file_2.py",
3719            "          file_3.py",
3720            "    v dir_2",
3721            "          file_1.py  <== selected  <== marked",
3722            "          file_2.py",
3723            "          file_3.py",
3724            "      .gitignore",
3725        ],
3726        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3727    );
3728
3729    panel.update(cx, |panel, cx| {
3730        panel.project.update(cx, |_, cx| {
3731            cx.emit(project::Event::ActiveEntryChanged(Some(
3732                gitignored_dir_file,
3733            )))
3734        })
3735    });
3736    cx.run_until_parked();
3737    assert_eq!(
3738        visible_entries_as_strings(&panel, 0..20, cx),
3739        &[
3740            "v project_root",
3741            "    > .git",
3742            "    v dir_1",
3743            "        > gitignored_dir",
3744            "          file_1.py",
3745            "          file_2.py",
3746            "          file_3.py",
3747            "    v dir_2",
3748            "          file_1.py  <== selected  <== marked",
3749            "          file_2.py",
3750            "          file_3.py",
3751            "      .gitignore",
3752        ],
3753        "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3754    );
3755
3756    panel.update(cx, |panel, cx| {
3757        panel.project.update(cx, |_, cx| {
3758            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3759        })
3760    });
3761    cx.run_until_parked();
3762    assert_eq!(
3763        visible_entries_as_strings(&panel, 0..20, cx),
3764        &[
3765            "v project_root",
3766            "    > .git",
3767            "    v dir_1",
3768            "        v gitignored_dir",
3769            "              file_a.py  <== selected  <== marked",
3770            "              file_b.py",
3771            "              file_c.py",
3772            "          file_1.py",
3773            "          file_2.py",
3774            "          file_3.py",
3775            "    v dir_2",
3776            "          file_1.py",
3777            "          file_2.py",
3778            "          file_3.py",
3779            "      .gitignore",
3780        ],
3781        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3782    );
3783}
3784
3785#[gpui::test]
3786async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3787    init_test_with_editor(cx);
3788    cx.update(|cx| {
3789        cx.update_global::<SettingsStore, _>(|store, cx| {
3790            store.update_user_settings(cx, |settings| {
3791                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3792                settings.project.worktree.file_scan_inclusions =
3793                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3794                settings
3795                    .project_panel
3796                    .get_or_insert_default()
3797                    .auto_reveal_entries = Some(false)
3798            });
3799        })
3800    });
3801
3802    let fs = FakeFs::new(cx.background_executor.clone());
3803    fs.insert_tree(
3804        "/project_root",
3805        json!({
3806            ".git": {},
3807            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3808            "dir_1": {
3809                "file_1.py": "# File 1_1 contents",
3810                "file_2.py": "# File 1_2 contents",
3811                "file_3.py": "# File 1_3 contents",
3812                "gitignored_dir": {
3813                    "file_a.py": "# File contents",
3814                    "file_b.py": "# File contents",
3815                    "file_c.py": "# File contents",
3816                },
3817            },
3818            "dir_2": {
3819                "file_1.py": "# File 2_1 contents",
3820                "file_2.py": "# File 2_2 contents",
3821                "file_3.py": "# File 2_3 contents",
3822            },
3823            "always_included_but_ignored_dir": {
3824                "file_a.py": "# File contents",
3825                "file_b.py": "# File contents",
3826                "file_c.py": "# File contents",
3827            },
3828        }),
3829    )
3830    .await;
3831
3832    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3833    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3834    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3835    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3836    cx.run_until_parked();
3837
3838    assert_eq!(
3839        visible_entries_as_strings(&panel, 0..20, cx),
3840        &[
3841            "v project_root",
3842            "    > .git",
3843            "    > always_included_but_ignored_dir",
3844            "    > dir_1",
3845            "    > dir_2",
3846            "      .gitignore",
3847        ]
3848    );
3849
3850    let gitignored_dir_file =
3851        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3852    let always_included_but_ignored_dir_file = find_project_entry(
3853        &panel,
3854        "project_root/always_included_but_ignored_dir/file_a.py",
3855        cx,
3856    )
3857    .expect("file that is .gitignored but set to always be included should have an entry");
3858    assert_eq!(
3859        gitignored_dir_file, None,
3860        "File in the gitignored dir should not have an entry unless its directory is toggled"
3861    );
3862
3863    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3864    cx.run_until_parked();
3865    cx.update(|_, cx| {
3866        cx.update_global::<SettingsStore, _>(|store, cx| {
3867            store.update_user_settings(cx, |settings| {
3868                settings
3869                    .project_panel
3870                    .get_or_insert_default()
3871                    .auto_reveal_entries = Some(true)
3872            });
3873        })
3874    });
3875
3876    panel.update(cx, |panel, cx| {
3877        panel.project.update(cx, |_, cx| {
3878            cx.emit(project::Event::ActiveEntryChanged(Some(
3879                always_included_but_ignored_dir_file,
3880            )))
3881        })
3882    });
3883    cx.run_until_parked();
3884
3885    assert_eq!(
3886        visible_entries_as_strings(&panel, 0..20, cx),
3887        &[
3888            "v project_root",
3889            "    > .git",
3890            "    v always_included_but_ignored_dir",
3891            "          file_a.py  <== selected  <== marked",
3892            "          file_b.py",
3893            "          file_c.py",
3894            "    v dir_1",
3895            "        > gitignored_dir",
3896            "          file_1.py",
3897            "          file_2.py",
3898            "          file_3.py",
3899            "    > dir_2",
3900            "      .gitignore",
3901        ],
3902        "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3903    );
3904}
3905
3906#[gpui::test]
3907async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3908    init_test_with_editor(cx);
3909    cx.update(|cx| {
3910        cx.update_global::<SettingsStore, _>(|store, cx| {
3911            store.update_user_settings(cx, |settings| {
3912                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3913                settings
3914                    .project_panel
3915                    .get_or_insert_default()
3916                    .auto_reveal_entries = Some(false)
3917            });
3918        })
3919    });
3920
3921    let fs = FakeFs::new(cx.background_executor.clone());
3922    fs.insert_tree(
3923        "/project_root",
3924        json!({
3925            ".git": {},
3926            ".gitignore": "**/gitignored_dir",
3927            "dir_1": {
3928                "file_1.py": "# File 1_1 contents",
3929                "file_2.py": "# File 1_2 contents",
3930                "file_3.py": "# File 1_3 contents",
3931                "gitignored_dir": {
3932                    "file_a.py": "# File contents",
3933                    "file_b.py": "# File contents",
3934                    "file_c.py": "# File contents",
3935                },
3936            },
3937            "dir_2": {
3938                "file_1.py": "# File 2_1 contents",
3939                "file_2.py": "# File 2_2 contents",
3940                "file_3.py": "# File 2_3 contents",
3941            }
3942        }),
3943    )
3944    .await;
3945
3946    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3947    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3948    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3949    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3950    cx.run_until_parked();
3951
3952    assert_eq!(
3953        visible_entries_as_strings(&panel, 0..20, cx),
3954        &[
3955            "v project_root",
3956            "    > .git",
3957            "    > dir_1",
3958            "    > dir_2",
3959            "      .gitignore",
3960        ]
3961    );
3962
3963    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3964        .expect("dir 1 file is not ignored and should have an entry");
3965    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3966        .expect("dir 2 file is not ignored and should have an entry");
3967    let gitignored_dir_file =
3968        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3969    assert_eq!(
3970        gitignored_dir_file, None,
3971        "File in the gitignored dir should not have an entry before its dir is toggled"
3972    );
3973
3974    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3975    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3976    cx.run_until_parked();
3977    assert_eq!(
3978        visible_entries_as_strings(&panel, 0..20, cx),
3979        &[
3980            "v project_root",
3981            "    > .git",
3982            "    v dir_1",
3983            "        v gitignored_dir  <== selected",
3984            "              file_a.py",
3985            "              file_b.py",
3986            "              file_c.py",
3987            "          file_1.py",
3988            "          file_2.py",
3989            "          file_3.py",
3990            "    > dir_2",
3991            "      .gitignore",
3992        ],
3993        "Should show gitignored dir file list in the project panel"
3994    );
3995    let gitignored_dir_file =
3996        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3997            .expect("after gitignored dir got opened, a file entry should be present");
3998
3999    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4000    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4001    assert_eq!(
4002        visible_entries_as_strings(&panel, 0..20, cx),
4003        &[
4004            "v project_root",
4005            "    > .git",
4006            "    > dir_1  <== selected",
4007            "    > dir_2",
4008            "      .gitignore",
4009        ],
4010        "Should hide all dir contents again and prepare for the explicit reveal test"
4011    );
4012
4013    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4014        panel.update(cx, |panel, cx| {
4015            panel.project.update(cx, |_, cx| {
4016                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4017            })
4018        });
4019        cx.run_until_parked();
4020        assert_eq!(
4021            visible_entries_as_strings(&panel, 0..20, cx),
4022            &[
4023                "v project_root",
4024                "    > .git",
4025                "    > dir_1  <== selected",
4026                "    > dir_2",
4027                "      .gitignore",
4028            ],
4029            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4030        );
4031    }
4032
4033    panel.update(cx, |panel, cx| {
4034        panel.project.update(cx, |_, cx| {
4035            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4036        })
4037    });
4038    cx.run_until_parked();
4039    assert_eq!(
4040        visible_entries_as_strings(&panel, 0..20, cx),
4041        &[
4042            "v project_root",
4043            "    > .git",
4044            "    v dir_1",
4045            "        > gitignored_dir",
4046            "          file_1.py  <== selected  <== marked",
4047            "          file_2.py",
4048            "          file_3.py",
4049            "    > dir_2",
4050            "      .gitignore",
4051        ],
4052        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4053    );
4054
4055    panel.update(cx, |panel, cx| {
4056        panel.project.update(cx, |_, cx| {
4057            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4058        })
4059    });
4060    cx.run_until_parked();
4061    assert_eq!(
4062        visible_entries_as_strings(&panel, 0..20, cx),
4063        &[
4064            "v project_root",
4065            "    > .git",
4066            "    v dir_1",
4067            "        > gitignored_dir",
4068            "          file_1.py",
4069            "          file_2.py",
4070            "          file_3.py",
4071            "    v dir_2",
4072            "          file_1.py  <== selected  <== marked",
4073            "          file_2.py",
4074            "          file_3.py",
4075            "      .gitignore",
4076        ],
4077        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4078    );
4079
4080    panel.update(cx, |panel, cx| {
4081        panel.project.update(cx, |_, cx| {
4082            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4083        })
4084    });
4085    cx.run_until_parked();
4086    assert_eq!(
4087        visible_entries_as_strings(&panel, 0..20, cx),
4088        &[
4089            "v project_root",
4090            "    > .git",
4091            "    v dir_1",
4092            "        v gitignored_dir",
4093            "              file_a.py  <== selected  <== marked",
4094            "              file_b.py",
4095            "              file_c.py",
4096            "          file_1.py",
4097            "          file_2.py",
4098            "          file_3.py",
4099            "    v dir_2",
4100            "          file_1.py",
4101            "          file_2.py",
4102            "          file_3.py",
4103            "      .gitignore",
4104        ],
4105        "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4106    );
4107}
4108
4109#[gpui::test]
4110async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4111    init_test(cx);
4112    cx.update(|cx| {
4113        cx.update_global::<SettingsStore, _>(|store, cx| {
4114            store.update_user_settings(cx, |settings| {
4115                settings.project.worktree.file_scan_exclusions =
4116                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4117            });
4118        });
4119    });
4120
4121    cx.update(|cx| {
4122        register_project_item::<TestProjectItemView>(cx);
4123    });
4124
4125    let fs = FakeFs::new(cx.executor());
4126    fs.insert_tree(
4127        "/root1",
4128        json!({
4129            ".dockerignore": "",
4130            ".git": {
4131                "HEAD": "",
4132            },
4133        }),
4134    )
4135    .await;
4136
4137    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4138    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4139    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4140    let panel = workspace
4141        .update(cx, |workspace, window, cx| {
4142            let panel = ProjectPanel::new(workspace, window, cx);
4143            workspace.add_panel(panel.clone(), window, cx);
4144            panel
4145        })
4146        .unwrap();
4147    cx.run_until_parked();
4148
4149    select_path(&panel, "root1", cx);
4150    assert_eq!(
4151        visible_entries_as_strings(&panel, 0..10, cx),
4152        &["v root1  <== selected", "      .dockerignore",]
4153    );
4154    workspace
4155        .update(cx, |workspace, _, cx| {
4156            assert!(
4157                workspace.active_item(cx).is_none(),
4158                "Should have no active items in the beginning"
4159            );
4160        })
4161        .unwrap();
4162
4163    let excluded_file_path = ".git/COMMIT_EDITMSG";
4164    let excluded_dir_path = "excluded_dir";
4165
4166    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
4167    cx.run_until_parked();
4168    panel.update_in(cx, |panel, window, cx| {
4169        assert!(panel.filename_editor.read(cx).is_focused(window));
4170    });
4171    panel
4172        .update_in(cx, |panel, window, cx| {
4173            panel.filename_editor.update(cx, |editor, cx| {
4174                editor.set_text(excluded_file_path, window, cx)
4175            });
4176            panel.confirm_edit(window, cx).unwrap()
4177        })
4178        .await
4179        .unwrap();
4180
4181    assert_eq!(
4182        visible_entries_as_strings(&panel, 0..13, cx),
4183        &["v root1", "      .dockerignore"],
4184        "Excluded dir should not be shown after opening a file in it"
4185    );
4186    panel.update_in(cx, |panel, window, cx| {
4187        assert!(
4188            !panel.filename_editor.read(cx).is_focused(window),
4189            "Should have closed the file name editor"
4190        );
4191    });
4192    workspace
4193        .update(cx, |workspace, _, cx| {
4194            let active_entry_path = workspace
4195                .active_item(cx)
4196                .expect("should have opened and activated the excluded item")
4197                .act_as::<TestProjectItemView>(cx)
4198                .expect("should have opened the corresponding project item for the excluded item")
4199                .read(cx)
4200                .path
4201                .clone();
4202            assert_eq!(
4203                active_entry_path.path.as_ref(),
4204                rel_path(excluded_file_path),
4205                "Should open the excluded file"
4206            );
4207
4208            assert!(
4209                workspace.notification_ids().is_empty(),
4210                "Should have no notifications after opening an excluded file"
4211            );
4212        })
4213        .unwrap();
4214    assert!(
4215        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4216        "Should have created the excluded file"
4217    );
4218
4219    select_path(&panel, "root1", cx);
4220    panel.update_in(cx, |panel, window, cx| {
4221        panel.new_directory(&NewDirectory, window, cx)
4222    });
4223    cx.run_until_parked();
4224    panel.update_in(cx, |panel, window, cx| {
4225        assert!(panel.filename_editor.read(cx).is_focused(window));
4226    });
4227    panel
4228        .update_in(cx, |panel, window, cx| {
4229            panel.filename_editor.update(cx, |editor, cx| {
4230                editor.set_text(excluded_file_path, window, cx)
4231            });
4232            panel.confirm_edit(window, cx).unwrap()
4233        })
4234        .await
4235        .unwrap();
4236    cx.run_until_parked();
4237    assert_eq!(
4238        visible_entries_as_strings(&panel, 0..13, cx),
4239        &["v root1", "      .dockerignore"],
4240        "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4241    );
4242    panel.update_in(cx, |panel, window, cx| {
4243        assert!(
4244            !panel.filename_editor.read(cx).is_focused(window),
4245            "Should have closed the file name editor"
4246        );
4247    });
4248    workspace
4249        .update(cx, |workspace, _, cx| {
4250            let notifications = workspace.notification_ids();
4251            assert_eq!(
4252                notifications.len(),
4253                1,
4254                "Should receive one notification with the error message"
4255            );
4256            workspace.dismiss_notification(notifications.first().unwrap(), cx);
4257            assert!(workspace.notification_ids().is_empty());
4258        })
4259        .unwrap();
4260
4261    select_path(&panel, "root1", cx);
4262    panel.update_in(cx, |panel, window, cx| {
4263        panel.new_directory(&NewDirectory, window, cx)
4264    });
4265    cx.run_until_parked();
4266
4267    panel.update_in(cx, |panel, window, cx| {
4268        assert!(panel.filename_editor.read(cx).is_focused(window));
4269    });
4270
4271    panel
4272        .update_in(cx, |panel, window, cx| {
4273            panel.filename_editor.update(cx, |editor, cx| {
4274                editor.set_text(excluded_dir_path, window, cx)
4275            });
4276            panel.confirm_edit(window, cx).unwrap()
4277        })
4278        .await
4279        .unwrap();
4280
4281    cx.run_until_parked();
4282
4283    assert_eq!(
4284        visible_entries_as_strings(&panel, 0..13, cx),
4285        &["v root1", "      .dockerignore"],
4286        "Should not change the project panel after trying to create an excluded directory"
4287    );
4288    panel.update_in(cx, |panel, window, cx| {
4289        assert!(
4290            !panel.filename_editor.read(cx).is_focused(window),
4291            "Should have closed the file name editor"
4292        );
4293    });
4294    workspace
4295        .update(cx, |workspace, _, cx| {
4296            let notifications = workspace.notification_ids();
4297            assert_eq!(
4298                notifications.len(),
4299                1,
4300                "Should receive one notification explaining that no directory is actually shown"
4301            );
4302            workspace.dismiss_notification(notifications.first().unwrap(), cx);
4303            assert!(workspace.notification_ids().is_empty());
4304        })
4305        .unwrap();
4306    assert!(
4307        fs.is_dir(Path::new("/root1/excluded_dir")).await,
4308        "Should have created the excluded directory"
4309    );
4310}
4311
4312#[gpui::test]
4313async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
4314    init_test_with_editor(cx);
4315
4316    let fs = FakeFs::new(cx.executor());
4317    fs.insert_tree(
4318        "/src",
4319        json!({
4320            "test": {
4321                "first.rs": "// First Rust file",
4322                "second.rs": "// Second Rust file",
4323                "third.rs": "// Third Rust file",
4324            }
4325        }),
4326    )
4327    .await;
4328
4329    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4330    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4331    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4332    let panel = workspace
4333        .update(cx, |workspace, window, cx| {
4334            let panel = ProjectPanel::new(workspace, window, cx);
4335            workspace.add_panel(panel.clone(), window, cx);
4336            panel
4337        })
4338        .unwrap();
4339    cx.run_until_parked();
4340
4341    select_path(&panel, "src", cx);
4342    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
4343    cx.executor().run_until_parked();
4344    assert_eq!(
4345        visible_entries_as_strings(&panel, 0..10, cx),
4346        &[
4347            //
4348            "v src  <== selected",
4349            "    > test"
4350        ]
4351    );
4352    panel.update_in(cx, |panel, window, cx| {
4353        panel.new_directory(&NewDirectory, window, cx)
4354    });
4355    cx.executor().run_until_parked();
4356    panel.update_in(cx, |panel, window, cx| {
4357        assert!(panel.filename_editor.read(cx).is_focused(window));
4358    });
4359    assert_eq!(
4360        visible_entries_as_strings(&panel, 0..10, cx),
4361        &[
4362            //
4363            "v src",
4364            "    > [EDITOR: '']  <== selected",
4365            "    > test"
4366        ]
4367    );
4368
4369    panel.update_in(cx, |panel, window, cx| {
4370        panel.cancel(&menu::Cancel, window, cx);
4371        panel.update_visible_entries(None, false, false, window, cx);
4372    });
4373    cx.executor().run_until_parked();
4374    assert_eq!(
4375        visible_entries_as_strings(&panel, 0..10, cx),
4376        &[
4377            //
4378            "v src  <== selected",
4379            "    > test"
4380        ]
4381    );
4382}
4383
4384#[gpui::test]
4385async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
4386    init_test_with_editor(cx);
4387
4388    let fs = FakeFs::new(cx.executor());
4389    fs.insert_tree(
4390        "/root",
4391        json!({
4392            "dir1": {
4393                "subdir1": {},
4394                "file1.txt": "",
4395                "file2.txt": "",
4396            },
4397            "dir2": {
4398                "subdir2": {},
4399                "file3.txt": "",
4400                "file4.txt": "",
4401            },
4402            "file5.txt": "",
4403            "file6.txt": "",
4404        }),
4405    )
4406    .await;
4407
4408    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4409    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4410    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4411    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4412    cx.run_until_parked();
4413
4414    toggle_expand_dir(&panel, "root/dir1", cx);
4415    toggle_expand_dir(&panel, "root/dir2", cx);
4416
4417    // Test Case 1: Delete middle file in directory
4418    select_path(&panel, "root/dir1/file1.txt", cx);
4419    assert_eq!(
4420        visible_entries_as_strings(&panel, 0..15, cx),
4421        &[
4422            "v root",
4423            "    v dir1",
4424            "        > subdir1",
4425            "          file1.txt  <== selected",
4426            "          file2.txt",
4427            "    v dir2",
4428            "        > subdir2",
4429            "          file3.txt",
4430            "          file4.txt",
4431            "      file5.txt",
4432            "      file6.txt",
4433        ],
4434        "Initial state before deleting middle file"
4435    );
4436
4437    submit_deletion(&panel, cx);
4438    assert_eq!(
4439        visible_entries_as_strings(&panel, 0..15, cx),
4440        &[
4441            "v root",
4442            "    v dir1",
4443            "        > subdir1",
4444            "          file2.txt  <== selected",
4445            "    v dir2",
4446            "        > subdir2",
4447            "          file3.txt",
4448            "          file4.txt",
4449            "      file5.txt",
4450            "      file6.txt",
4451        ],
4452        "Should select next file after deleting middle file"
4453    );
4454
4455    // Test Case 2: Delete last file in directory
4456    submit_deletion(&panel, cx);
4457    assert_eq!(
4458        visible_entries_as_strings(&panel, 0..15, cx),
4459        &[
4460            "v root",
4461            "    v dir1",
4462            "        > subdir1  <== selected",
4463            "    v dir2",
4464            "        > subdir2",
4465            "          file3.txt",
4466            "          file4.txt",
4467            "      file5.txt",
4468            "      file6.txt",
4469        ],
4470        "Should select next directory when last file is deleted"
4471    );
4472
4473    // Test Case 3: Delete root level file
4474    select_path(&panel, "root/file6.txt", cx);
4475    assert_eq!(
4476        visible_entries_as_strings(&panel, 0..15, cx),
4477        &[
4478            "v root",
4479            "    v dir1",
4480            "        > subdir1",
4481            "    v dir2",
4482            "        > subdir2",
4483            "          file3.txt",
4484            "          file4.txt",
4485            "      file5.txt",
4486            "      file6.txt  <== selected",
4487        ],
4488        "Initial state before deleting root level file"
4489    );
4490
4491    submit_deletion(&panel, cx);
4492    assert_eq!(
4493        visible_entries_as_strings(&panel, 0..15, cx),
4494        &[
4495            "v root",
4496            "    v dir1",
4497            "        > subdir1",
4498            "    v dir2",
4499            "        > subdir2",
4500            "          file3.txt",
4501            "          file4.txt",
4502            "      file5.txt  <== selected",
4503        ],
4504        "Should select prev entry at root level"
4505    );
4506}
4507
4508#[gpui::test]
4509async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
4510    init_test_with_editor(cx);
4511
4512    let fs = FakeFs::new(cx.executor());
4513    fs.insert_tree(
4514        path!("/root"),
4515        json!({
4516            "aa": "// Testing 1",
4517            "bb": "// Testing 2",
4518            "cc": "// Testing 3",
4519            "dd": "// Testing 4",
4520            "ee": "// Testing 5",
4521            "ff": "// Testing 6",
4522            "gg": "// Testing 7",
4523            "hh": "// Testing 8",
4524            "ii": "// Testing 8",
4525            ".gitignore": "bb\ndd\nee\nff\nii\n'",
4526        }),
4527    )
4528    .await;
4529
4530    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4531    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4532    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4533
4534    // Test 1: Auto selection with one gitignored file next to the deleted file
4535    cx.update(|_, cx| {
4536        let settings = *ProjectPanelSettings::get_global(cx);
4537        ProjectPanelSettings::override_global(
4538            ProjectPanelSettings {
4539                hide_gitignore: true,
4540                ..settings
4541            },
4542            cx,
4543        );
4544    });
4545
4546    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4547    cx.run_until_parked();
4548
4549    select_path(&panel, "root/aa", cx);
4550    assert_eq!(
4551        visible_entries_as_strings(&panel, 0..10, cx),
4552        &[
4553            "v root",
4554            "      .gitignore",
4555            "      aa  <== selected",
4556            "      cc",
4557            "      gg",
4558            "      hh"
4559        ],
4560        "Initial state should hide files on .gitignore"
4561    );
4562
4563    submit_deletion(&panel, cx);
4564
4565    assert_eq!(
4566        visible_entries_as_strings(&panel, 0..10, cx),
4567        &[
4568            "v root",
4569            "      .gitignore",
4570            "      cc  <== selected",
4571            "      gg",
4572            "      hh"
4573        ],
4574        "Should select next entry not on .gitignore"
4575    );
4576
4577    // Test 2: Auto selection with many gitignored files next to the deleted file
4578    submit_deletion(&panel, cx);
4579    assert_eq!(
4580        visible_entries_as_strings(&panel, 0..10, cx),
4581        &[
4582            "v root",
4583            "      .gitignore",
4584            "      gg  <== selected",
4585            "      hh"
4586        ],
4587        "Should select next entry not on .gitignore"
4588    );
4589
4590    // Test 3: Auto selection of entry before deleted file
4591    select_path(&panel, "root/hh", cx);
4592    assert_eq!(
4593        visible_entries_as_strings(&panel, 0..10, cx),
4594        &[
4595            "v root",
4596            "      .gitignore",
4597            "      gg",
4598            "      hh  <== selected"
4599        ],
4600        "Should select next entry not on .gitignore"
4601    );
4602    submit_deletion(&panel, cx);
4603    assert_eq!(
4604        visible_entries_as_strings(&panel, 0..10, cx),
4605        &["v root", "      .gitignore", "      gg  <== selected"],
4606        "Should select next entry not on .gitignore"
4607    );
4608}
4609
4610#[gpui::test]
4611async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
4612    init_test_with_editor(cx);
4613
4614    let fs = FakeFs::new(cx.executor());
4615    fs.insert_tree(
4616        path!("/root"),
4617        json!({
4618            "dir1": {
4619                "file1": "// Testing",
4620                "file2": "// Testing",
4621                "file3": "// Testing"
4622            },
4623            "aa": "// Testing",
4624            ".gitignore": "file1\nfile3\n",
4625        }),
4626    )
4627    .await;
4628
4629    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4630    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4631    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4632
4633    cx.update(|_, cx| {
4634        let settings = *ProjectPanelSettings::get_global(cx);
4635        ProjectPanelSettings::override_global(
4636            ProjectPanelSettings {
4637                hide_gitignore: true,
4638                ..settings
4639            },
4640            cx,
4641        );
4642    });
4643
4644    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4645    cx.run_until_parked();
4646
4647    // Test 1: Visible items should exclude files on gitignore
4648    toggle_expand_dir(&panel, "root/dir1", cx);
4649    select_path(&panel, "root/dir1/file2", cx);
4650    assert_eq!(
4651        visible_entries_as_strings(&panel, 0..10, cx),
4652        &[
4653            "v root",
4654            "    v dir1",
4655            "          file2  <== selected",
4656            "      .gitignore",
4657            "      aa"
4658        ],
4659        "Initial state should hide files on .gitignore"
4660    );
4661    submit_deletion(&panel, cx);
4662
4663    // Test 2: Auto selection should go to the parent
4664    assert_eq!(
4665        visible_entries_as_strings(&panel, 0..10, cx),
4666        &[
4667            "v root",
4668            "    v dir1  <== selected",
4669            "      .gitignore",
4670            "      aa"
4671        ],
4672        "Initial state should hide files on .gitignore"
4673    );
4674}
4675
4676#[gpui::test]
4677async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
4678    init_test_with_editor(cx);
4679
4680    let fs = FakeFs::new(cx.executor());
4681    fs.insert_tree(
4682        "/root",
4683        json!({
4684            "dir1": {
4685                "subdir1": {
4686                    "a.txt": "",
4687                    "b.txt": ""
4688                },
4689                "file1.txt": "",
4690            },
4691            "dir2": {
4692                "subdir2": {
4693                    "c.txt": "",
4694                    "d.txt": ""
4695                },
4696                "file2.txt": "",
4697            },
4698            "file3.txt": "",
4699        }),
4700    )
4701    .await;
4702
4703    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4704    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4705    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4706    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4707    cx.run_until_parked();
4708
4709    toggle_expand_dir(&panel, "root/dir1", cx);
4710    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4711    toggle_expand_dir(&panel, "root/dir2", cx);
4712    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4713
4714    // Test Case 1: Select and delete nested directory with parent
4715    cx.simulate_modifiers_change(gpui::Modifiers {
4716        control: true,
4717        ..Default::default()
4718    });
4719    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4720    select_path_with_mark(&panel, "root/dir1", cx);
4721
4722    assert_eq!(
4723        visible_entries_as_strings(&panel, 0..15, cx),
4724        &[
4725            "v root",
4726            "    v dir1  <== selected  <== marked",
4727            "        v subdir1  <== marked",
4728            "              a.txt",
4729            "              b.txt",
4730            "          file1.txt",
4731            "    v dir2",
4732            "        v subdir2",
4733            "              c.txt",
4734            "              d.txt",
4735            "          file2.txt",
4736            "      file3.txt",
4737        ],
4738        "Initial state before deleting nested directory with parent"
4739    );
4740
4741    submit_deletion(&panel, cx);
4742    assert_eq!(
4743        visible_entries_as_strings(&panel, 0..15, cx),
4744        &[
4745            "v root",
4746            "    v dir2  <== selected",
4747            "        v subdir2",
4748            "              c.txt",
4749            "              d.txt",
4750            "          file2.txt",
4751            "      file3.txt",
4752        ],
4753        "Should select next directory after deleting directory with parent"
4754    );
4755
4756    // Test Case 2: Select mixed files and directories across levels
4757    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4758    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4759    select_path_with_mark(&panel, "root/file3.txt", cx);
4760
4761    assert_eq!(
4762        visible_entries_as_strings(&panel, 0..15, cx),
4763        &[
4764            "v root",
4765            "    v dir2",
4766            "        v subdir2",
4767            "              c.txt  <== marked",
4768            "              d.txt",
4769            "          file2.txt  <== marked",
4770            "      file3.txt  <== selected  <== marked",
4771        ],
4772        "Initial state before deleting"
4773    );
4774
4775    submit_deletion(&panel, cx);
4776    assert_eq!(
4777        visible_entries_as_strings(&panel, 0..15, cx),
4778        &[
4779            "v root",
4780            "    v dir2  <== selected",
4781            "        v subdir2",
4782            "              d.txt",
4783        ],
4784        "Should select sibling directory"
4785    );
4786}
4787
4788#[gpui::test]
4789async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4790    init_test_with_editor(cx);
4791
4792    let fs = FakeFs::new(cx.executor());
4793    fs.insert_tree(
4794        "/root",
4795        json!({
4796            "dir1": {
4797                "subdir1": {
4798                    "a.txt": "",
4799                    "b.txt": ""
4800                },
4801                "file1.txt": "",
4802            },
4803            "dir2": {
4804                "subdir2": {
4805                    "c.txt": "",
4806                    "d.txt": ""
4807                },
4808                "file2.txt": "",
4809            },
4810            "file3.txt": "",
4811            "file4.txt": "",
4812        }),
4813    )
4814    .await;
4815
4816    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4817    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4818    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4819    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4820    cx.run_until_parked();
4821
4822    toggle_expand_dir(&panel, "root/dir1", cx);
4823    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4824    toggle_expand_dir(&panel, "root/dir2", cx);
4825    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4826
4827    // Test Case 1: Select all root files and directories
4828    cx.simulate_modifiers_change(gpui::Modifiers {
4829        control: true,
4830        ..Default::default()
4831    });
4832    select_path_with_mark(&panel, "root/dir1", cx);
4833    select_path_with_mark(&panel, "root/dir2", cx);
4834    select_path_with_mark(&panel, "root/file3.txt", cx);
4835    select_path_with_mark(&panel, "root/file4.txt", cx);
4836    assert_eq!(
4837        visible_entries_as_strings(&panel, 0..20, cx),
4838        &[
4839            "v root",
4840            "    v dir1  <== marked",
4841            "        v subdir1",
4842            "              a.txt",
4843            "              b.txt",
4844            "          file1.txt",
4845            "    v dir2  <== marked",
4846            "        v subdir2",
4847            "              c.txt",
4848            "              d.txt",
4849            "          file2.txt",
4850            "      file3.txt  <== marked",
4851            "      file4.txt  <== selected  <== marked",
4852        ],
4853        "State before deleting all contents"
4854    );
4855
4856    submit_deletion(&panel, cx);
4857    assert_eq!(
4858        visible_entries_as_strings(&panel, 0..20, cx),
4859        &["v root  <== selected"],
4860        "Only empty root directory should remain after deleting all contents"
4861    );
4862}
4863
4864#[gpui::test]
4865async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4866    init_test_with_editor(cx);
4867
4868    let fs = FakeFs::new(cx.executor());
4869    fs.insert_tree(
4870        "/root",
4871        json!({
4872            "dir1": {
4873                "subdir1": {
4874                    "file_a.txt": "content a",
4875                    "file_b.txt": "content b",
4876                },
4877                "subdir2": {
4878                    "file_c.txt": "content c",
4879                },
4880                "file1.txt": "content 1",
4881            },
4882            "dir2": {
4883                "file2.txt": "content 2",
4884            },
4885        }),
4886    )
4887    .await;
4888
4889    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4890    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4891    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4892    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4893    cx.run_until_parked();
4894
4895    toggle_expand_dir(&panel, "root/dir1", cx);
4896    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4897    toggle_expand_dir(&panel, "root/dir2", cx);
4898    cx.simulate_modifiers_change(gpui::Modifiers {
4899        control: true,
4900        ..Default::default()
4901    });
4902
4903    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4904    select_path_with_mark(&panel, "root/dir1", cx);
4905    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4906    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4907
4908    assert_eq!(
4909        visible_entries_as_strings(&panel, 0..20, cx),
4910        &[
4911            "v root",
4912            "    v dir1  <== marked",
4913            "        v subdir1  <== marked",
4914            "              file_a.txt  <== selected  <== marked",
4915            "              file_b.txt",
4916            "        > subdir2",
4917            "          file1.txt",
4918            "    v dir2",
4919            "          file2.txt",
4920        ],
4921        "State with parent dir, subdir, and file selected"
4922    );
4923    submit_deletion(&panel, cx);
4924    assert_eq!(
4925        visible_entries_as_strings(&panel, 0..20, cx),
4926        &["v root", "    v dir2  <== selected", "          file2.txt",],
4927        "Only dir2 should remain after deletion"
4928    );
4929}
4930
4931#[gpui::test]
4932async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4933    init_test_with_editor(cx);
4934
4935    let fs = FakeFs::new(cx.executor());
4936    // First worktree
4937    fs.insert_tree(
4938        "/root1",
4939        json!({
4940            "dir1": {
4941                "file1.txt": "content 1",
4942                "file2.txt": "content 2",
4943            },
4944            "dir2": {
4945                "file3.txt": "content 3",
4946            },
4947        }),
4948    )
4949    .await;
4950
4951    // Second worktree
4952    fs.insert_tree(
4953        "/root2",
4954        json!({
4955            "dir3": {
4956                "file4.txt": "content 4",
4957                "file5.txt": "content 5",
4958            },
4959            "file6.txt": "content 6",
4960        }),
4961    )
4962    .await;
4963
4964    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4965    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4966    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4967    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4968    cx.run_until_parked();
4969
4970    // Expand all directories for testing
4971    toggle_expand_dir(&panel, "root1/dir1", cx);
4972    toggle_expand_dir(&panel, "root1/dir2", cx);
4973    toggle_expand_dir(&panel, "root2/dir3", cx);
4974
4975    // Test Case 1: Delete files across different worktrees
4976    cx.simulate_modifiers_change(gpui::Modifiers {
4977        control: true,
4978        ..Default::default()
4979    });
4980    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4981    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4982
4983    assert_eq!(
4984        visible_entries_as_strings(&panel, 0..20, cx),
4985        &[
4986            "v root1",
4987            "    v dir1",
4988            "          file1.txt  <== marked",
4989            "          file2.txt",
4990            "    v dir2",
4991            "          file3.txt",
4992            "v root2",
4993            "    v dir3",
4994            "          file4.txt  <== selected  <== marked",
4995            "          file5.txt",
4996            "      file6.txt",
4997        ],
4998        "Initial state with files selected from different worktrees"
4999    );
5000
5001    submit_deletion(&panel, cx);
5002    assert_eq!(
5003        visible_entries_as_strings(&panel, 0..20, cx),
5004        &[
5005            "v root1",
5006            "    v dir1",
5007            "          file2.txt",
5008            "    v dir2",
5009            "          file3.txt",
5010            "v root2",
5011            "    v dir3",
5012            "          file5.txt  <== selected",
5013            "      file6.txt",
5014        ],
5015        "Should select next file in the last worktree after deletion"
5016    );
5017
5018    // Test Case 2: Delete directories from different worktrees
5019    select_path_with_mark(&panel, "root1/dir1", cx);
5020    select_path_with_mark(&panel, "root2/dir3", cx);
5021
5022    assert_eq!(
5023        visible_entries_as_strings(&panel, 0..20, cx),
5024        &[
5025            "v root1",
5026            "    v dir1  <== marked",
5027            "          file2.txt",
5028            "    v dir2",
5029            "          file3.txt",
5030            "v root2",
5031            "    v dir3  <== selected  <== marked",
5032            "          file5.txt",
5033            "      file6.txt",
5034        ],
5035        "State with directories marked from different worktrees"
5036    );
5037
5038    submit_deletion(&panel, cx);
5039    assert_eq!(
5040        visible_entries_as_strings(&panel, 0..20, cx),
5041        &[
5042            "v root1",
5043            "    v dir2",
5044            "          file3.txt",
5045            "v root2",
5046            "      file6.txt  <== selected",
5047        ],
5048        "Should select remaining file in last worktree after directory deletion"
5049    );
5050
5051    // Test Case 4: Delete all remaining files except roots
5052    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
5053    select_path_with_mark(&panel, "root2/file6.txt", cx);
5054
5055    assert_eq!(
5056        visible_entries_as_strings(&panel, 0..20, cx),
5057        &[
5058            "v root1",
5059            "    v dir2",
5060            "          file3.txt  <== marked",
5061            "v root2",
5062            "      file6.txt  <== selected  <== marked",
5063        ],
5064        "State with all remaining files marked"
5065    );
5066
5067    submit_deletion(&panel, cx);
5068    assert_eq!(
5069        visible_entries_as_strings(&panel, 0..20, cx),
5070        &["v root1", "    v dir2", "v root2  <== selected"],
5071        "Second parent root should be selected after deleting"
5072    );
5073}
5074
5075#[gpui::test]
5076async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
5077    init_test_with_editor(cx);
5078
5079    let fs = FakeFs::new(cx.executor());
5080    fs.insert_tree(
5081        "/root",
5082        json!({
5083            "dir1": {
5084                "file1.txt": "",
5085                "file2.txt": "",
5086                "file3.txt": "",
5087            },
5088            "dir2": {
5089                "file4.txt": "",
5090                "file5.txt": "",
5091            },
5092        }),
5093    )
5094    .await;
5095
5096    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5097    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5098    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5099    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5100    cx.run_until_parked();
5101
5102    toggle_expand_dir(&panel, "root/dir1", cx);
5103    toggle_expand_dir(&panel, "root/dir2", cx);
5104
5105    cx.simulate_modifiers_change(gpui::Modifiers {
5106        control: true,
5107        ..Default::default()
5108    });
5109
5110    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
5111    select_path(&panel, "root/dir1/file1.txt", cx);
5112
5113    assert_eq!(
5114        visible_entries_as_strings(&panel, 0..15, cx),
5115        &[
5116            "v root",
5117            "    v dir1",
5118            "          file1.txt  <== selected",
5119            "          file2.txt  <== marked",
5120            "          file3.txt",
5121            "    v dir2",
5122            "          file4.txt",
5123            "          file5.txt",
5124        ],
5125        "Initial state with one marked entry and different selection"
5126    );
5127
5128    // Delete should operate on the selected entry (file1.txt)
5129    submit_deletion(&panel, cx);
5130    assert_eq!(
5131        visible_entries_as_strings(&panel, 0..15, cx),
5132        &[
5133            "v root",
5134            "    v dir1",
5135            "          file2.txt  <== selected  <== marked",
5136            "          file3.txt",
5137            "    v dir2",
5138            "          file4.txt",
5139            "          file5.txt",
5140        ],
5141        "Should delete selected file, not marked file"
5142    );
5143
5144    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
5145    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
5146    select_path(&panel, "root/dir2/file5.txt", cx);
5147
5148    assert_eq!(
5149        visible_entries_as_strings(&panel, 0..15, cx),
5150        &[
5151            "v root",
5152            "    v dir1",
5153            "          file2.txt  <== marked",
5154            "          file3.txt  <== marked",
5155            "    v dir2",
5156            "          file4.txt  <== marked",
5157            "          file5.txt  <== selected",
5158        ],
5159        "Initial state with multiple marked entries and different selection"
5160    );
5161
5162    // Delete should operate on all marked entries, ignoring the selection
5163    submit_deletion(&panel, cx);
5164    assert_eq!(
5165        visible_entries_as_strings(&panel, 0..15, cx),
5166        &[
5167            "v root",
5168            "    v dir1",
5169            "    v dir2",
5170            "          file5.txt  <== selected",
5171        ],
5172        "Should delete all marked files, leaving only the selected file"
5173    );
5174}
5175
5176#[gpui::test]
5177async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
5178    init_test_with_editor(cx);
5179
5180    let fs = FakeFs::new(cx.executor());
5181    fs.insert_tree(
5182        "/root_b",
5183        json!({
5184            "dir1": {
5185                "file1.txt": "content 1",
5186                "file2.txt": "content 2",
5187            },
5188        }),
5189    )
5190    .await;
5191
5192    fs.insert_tree(
5193        "/root_c",
5194        json!({
5195            "dir2": {},
5196        }),
5197    )
5198    .await;
5199
5200    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
5201    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5202    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5203    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5204    cx.run_until_parked();
5205
5206    toggle_expand_dir(&panel, "root_b/dir1", cx);
5207    toggle_expand_dir(&panel, "root_c/dir2", cx);
5208
5209    cx.simulate_modifiers_change(gpui::Modifiers {
5210        control: true,
5211        ..Default::default()
5212    });
5213    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
5214    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
5215
5216    assert_eq!(
5217        visible_entries_as_strings(&panel, 0..20, cx),
5218        &[
5219            "v root_b",
5220            "    v dir1",
5221            "          file1.txt  <== marked",
5222            "          file2.txt  <== selected  <== marked",
5223            "v root_c",
5224            "    v dir2",
5225        ],
5226        "Initial state with files marked in root_b"
5227    );
5228
5229    submit_deletion(&panel, cx);
5230    assert_eq!(
5231        visible_entries_as_strings(&panel, 0..20, cx),
5232        &[
5233            "v root_b",
5234            "    v dir1  <== selected",
5235            "v root_c",
5236            "    v dir2",
5237        ],
5238        "After deletion in root_b as it's last deletion, selection should be in root_b"
5239    );
5240
5241    select_path_with_mark(&panel, "root_c/dir2", cx);
5242
5243    submit_deletion(&panel, cx);
5244    assert_eq!(
5245        visible_entries_as_strings(&panel, 0..20, cx),
5246        &["v root_b", "    v dir1", "v root_c  <== selected",],
5247        "After deleting from root_c, it should remain in root_c"
5248    );
5249}
5250
5251fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
5252    let path = rel_path(path);
5253    panel.update_in(cx, |panel, window, cx| {
5254        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5255            let worktree = worktree.read(cx);
5256            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5257                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5258                panel.toggle_expanded(entry_id, window, cx);
5259                return;
5260            }
5261        }
5262        panic!("no worktree for path {:?}", path);
5263    });
5264    cx.run_until_parked();
5265}
5266
5267#[gpui::test]
5268async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
5269    init_test_with_editor(cx);
5270
5271    let fs = FakeFs::new(cx.executor());
5272    fs.insert_tree(
5273        path!("/root"),
5274        json!({
5275            ".gitignore": "**/ignored_dir\n**/ignored_nested",
5276            "dir1": {
5277                "empty1": {
5278                    "empty2": {
5279                        "empty3": {
5280                            "file.txt": ""
5281                        }
5282                    }
5283                },
5284                "subdir1": {
5285                    "file1.txt": "",
5286                    "file2.txt": "",
5287                    "ignored_nested": {
5288                        "ignored_file.txt": ""
5289                    }
5290                },
5291                "ignored_dir": {
5292                    "subdir": {
5293                        "deep_file.txt": ""
5294                    }
5295                }
5296            }
5297        }),
5298    )
5299    .await;
5300
5301    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5302    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5303    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5304
5305    // Test 1: When auto-fold is enabled
5306    cx.update(|_, cx| {
5307        let settings = *ProjectPanelSettings::get_global(cx);
5308        ProjectPanelSettings::override_global(
5309            ProjectPanelSettings {
5310                auto_fold_dirs: true,
5311                ..settings
5312            },
5313            cx,
5314        );
5315    });
5316
5317    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5318    cx.run_until_parked();
5319
5320    assert_eq!(
5321        visible_entries_as_strings(&panel, 0..20, cx),
5322        &["v root", "    > dir1", "      .gitignore",],
5323        "Initial state should show collapsed root structure"
5324    );
5325
5326    toggle_expand_dir(&panel, "root/dir1", cx);
5327    assert_eq!(
5328        visible_entries_as_strings(&panel, 0..20, cx),
5329        &[
5330            "v root",
5331            "    v dir1  <== selected",
5332            "        > empty1/empty2/empty3",
5333            "        > ignored_dir",
5334            "        > subdir1",
5335            "      .gitignore",
5336        ],
5337        "Should show first level with auto-folded dirs and ignored dir visible"
5338    );
5339
5340    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5341    panel.update_in(cx, |panel, window, cx| {
5342        let project = panel.project.read(cx);
5343        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5344        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5345        panel.update_visible_entries(None, false, false, window, cx);
5346    });
5347    cx.run_until_parked();
5348
5349    assert_eq!(
5350        visible_entries_as_strings(&panel, 0..20, cx),
5351        &[
5352            "v root",
5353            "    v dir1  <== selected",
5354            "        v empty1",
5355            "            v empty2",
5356            "                v empty3",
5357            "                      file.txt",
5358            "        > ignored_dir",
5359            "        v subdir1",
5360            "            > ignored_nested",
5361            "              file1.txt",
5362            "              file2.txt",
5363            "      .gitignore",
5364        ],
5365        "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
5366    );
5367
5368    // Test 2: When auto-fold is disabled
5369    cx.update(|_, cx| {
5370        let settings = *ProjectPanelSettings::get_global(cx);
5371        ProjectPanelSettings::override_global(
5372            ProjectPanelSettings {
5373                auto_fold_dirs: false,
5374                ..settings
5375            },
5376            cx,
5377        );
5378    });
5379
5380    panel.update_in(cx, |panel, window, cx| {
5381        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
5382    });
5383
5384    toggle_expand_dir(&panel, "root/dir1", cx);
5385    assert_eq!(
5386        visible_entries_as_strings(&panel, 0..20, cx),
5387        &[
5388            "v root",
5389            "    v dir1  <== selected",
5390            "        > empty1",
5391            "        > ignored_dir",
5392            "        > subdir1",
5393            "      .gitignore",
5394        ],
5395        "With auto-fold disabled: should show all directories separately"
5396    );
5397
5398    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5399    panel.update_in(cx, |panel, window, cx| {
5400        let project = panel.project.read(cx);
5401        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5402        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5403        panel.update_visible_entries(None, false, false, window, cx);
5404    });
5405    cx.run_until_parked();
5406
5407    assert_eq!(
5408        visible_entries_as_strings(&panel, 0..20, cx),
5409        &[
5410            "v root",
5411            "    v dir1  <== selected",
5412            "        v empty1",
5413            "            v empty2",
5414            "                v empty3",
5415            "                      file.txt",
5416            "        > ignored_dir",
5417            "        v subdir1",
5418            "            > ignored_nested",
5419            "              file1.txt",
5420            "              file2.txt",
5421            "      .gitignore",
5422        ],
5423        "After expand_all without auto-fold: should expand all dirs normally, \
5424         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
5425    );
5426
5427    // Test 3: When explicitly called on ignored directory
5428    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
5429    panel.update_in(cx, |panel, window, cx| {
5430        let project = panel.project.read(cx);
5431        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5432        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
5433        panel.update_visible_entries(None, false, false, window, cx);
5434    });
5435    cx.run_until_parked();
5436
5437    assert_eq!(
5438        visible_entries_as_strings(&panel, 0..20, cx),
5439        &[
5440            "v root",
5441            "    v dir1  <== selected",
5442            "        v empty1",
5443            "            v empty2",
5444            "                v empty3",
5445            "                      file.txt",
5446            "        v ignored_dir",
5447            "            v subdir",
5448            "                  deep_file.txt",
5449            "        v subdir1",
5450            "            > ignored_nested",
5451            "              file1.txt",
5452            "              file2.txt",
5453            "      .gitignore",
5454        ],
5455        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
5456    );
5457}
5458
5459#[gpui::test]
5460async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
5461    init_test(cx);
5462
5463    let fs = FakeFs::new(cx.executor());
5464    fs.insert_tree(
5465        path!("/root"),
5466        json!({
5467            "dir1": {
5468                "subdir1": {
5469                    "nested1": {
5470                        "file1.txt": "",
5471                        "file2.txt": ""
5472                    },
5473                },
5474                "subdir2": {
5475                    "file4.txt": ""
5476                }
5477            },
5478            "dir2": {
5479                "single_file": {
5480                    "file5.txt": ""
5481                }
5482            }
5483        }),
5484    )
5485    .await;
5486
5487    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5488    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5489    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5490
5491    // Test 1: Basic collapsing
5492    {
5493        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5494        cx.run_until_parked();
5495
5496        toggle_expand_dir(&panel, "root/dir1", cx);
5497        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5498        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5499        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
5500
5501        assert_eq!(
5502            visible_entries_as_strings(&panel, 0..20, cx),
5503            &[
5504                "v root",
5505                "    v dir1",
5506                "        v subdir1",
5507                "            v nested1",
5508                "                  file1.txt",
5509                "                  file2.txt",
5510                "        v subdir2  <== selected",
5511                "              file4.txt",
5512                "    > dir2",
5513            ],
5514            "Initial state with everything expanded"
5515        );
5516
5517        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5518        panel.update_in(cx, |panel, window, cx| {
5519            let project = panel.project.read(cx);
5520            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5521            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5522            panel.update_visible_entries(None, false, false, window, cx);
5523        });
5524        cx.run_until_parked();
5525
5526        assert_eq!(
5527            visible_entries_as_strings(&panel, 0..20, cx),
5528            &["v root", "    > dir1", "    > dir2",],
5529            "All subdirs under dir1 should be collapsed"
5530        );
5531    }
5532
5533    // Test 2: With auto-fold enabled
5534    {
5535        cx.update(|_, cx| {
5536            let settings = *ProjectPanelSettings::get_global(cx);
5537            ProjectPanelSettings::override_global(
5538                ProjectPanelSettings {
5539                    auto_fold_dirs: true,
5540                    ..settings
5541                },
5542                cx,
5543            );
5544        });
5545
5546        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5547        cx.run_until_parked();
5548
5549        toggle_expand_dir(&panel, "root/dir1", cx);
5550        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5551        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5552
5553        assert_eq!(
5554            visible_entries_as_strings(&panel, 0..20, cx),
5555            &[
5556                "v root",
5557                "    v dir1",
5558                "        v subdir1/nested1  <== selected",
5559                "              file1.txt",
5560                "              file2.txt",
5561                "        > subdir2",
5562                "    > dir2/single_file",
5563            ],
5564            "Initial state with some dirs expanded"
5565        );
5566
5567        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5568        panel.update(cx, |panel, cx| {
5569            let project = panel.project.read(cx);
5570            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5571            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5572        });
5573
5574        toggle_expand_dir(&panel, "root/dir1", cx);
5575
5576        assert_eq!(
5577            visible_entries_as_strings(&panel, 0..20, cx),
5578            &[
5579                "v root",
5580                "    v dir1  <== selected",
5581                "        > subdir1/nested1",
5582                "        > subdir2",
5583                "    > dir2/single_file",
5584            ],
5585            "Subdirs should be collapsed and folded with auto-fold enabled"
5586        );
5587    }
5588
5589    // Test 3: With auto-fold disabled
5590    {
5591        cx.update(|_, cx| {
5592            let settings = *ProjectPanelSettings::get_global(cx);
5593            ProjectPanelSettings::override_global(
5594                ProjectPanelSettings {
5595                    auto_fold_dirs: false,
5596                    ..settings
5597                },
5598                cx,
5599            );
5600        });
5601
5602        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5603        cx.run_until_parked();
5604
5605        toggle_expand_dir(&panel, "root/dir1", cx);
5606        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5607        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5608
5609        assert_eq!(
5610            visible_entries_as_strings(&panel, 0..20, cx),
5611            &[
5612                "v root",
5613                "    v dir1",
5614                "        v subdir1",
5615                "            v nested1  <== selected",
5616                "                  file1.txt",
5617                "                  file2.txt",
5618                "        > subdir2",
5619                "    > dir2",
5620            ],
5621            "Initial state with some dirs expanded and auto-fold disabled"
5622        );
5623
5624        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5625        panel.update(cx, |panel, cx| {
5626            let project = panel.project.read(cx);
5627            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5628            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5629        });
5630
5631        toggle_expand_dir(&panel, "root/dir1", cx);
5632
5633        assert_eq!(
5634            visible_entries_as_strings(&panel, 0..20, cx),
5635            &[
5636                "v root",
5637                "    v dir1  <== selected",
5638                "        > subdir1",
5639                "        > subdir2",
5640                "    > dir2",
5641            ],
5642            "Subdirs should be collapsed but not folded with auto-fold disabled"
5643        );
5644    }
5645}
5646
5647#[gpui::test]
5648async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
5649    init_test(cx);
5650
5651    let fs = FakeFs::new(cx.executor());
5652    fs.insert_tree(
5653        path!("/root"),
5654        json!({
5655            "dir1": {
5656                "file1.txt": "",
5657            },
5658        }),
5659    )
5660    .await;
5661
5662    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5663    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5664    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5665
5666    let panel = workspace
5667        .update(cx, |workspace, window, cx| {
5668            let panel = ProjectPanel::new(workspace, window, cx);
5669            workspace.add_panel(panel.clone(), window, cx);
5670            panel
5671        })
5672        .unwrap();
5673    cx.run_until_parked();
5674
5675    #[rustfmt::skip]
5676    assert_eq!(
5677        visible_entries_as_strings(&panel, 0..20, cx),
5678        &[
5679            "v root",
5680            "    > dir1",
5681        ],
5682        "Initial state with nothing selected"
5683    );
5684
5685    panel.update_in(cx, |panel, window, cx| {
5686        panel.new_file(&NewFile, window, cx);
5687    });
5688    cx.run_until_parked();
5689    panel.update_in(cx, |panel, window, cx| {
5690        assert!(panel.filename_editor.read(cx).is_focused(window));
5691    });
5692    panel
5693        .update_in(cx, |panel, window, cx| {
5694            panel.filename_editor.update(cx, |editor, cx| {
5695                editor.set_text("hello_from_no_selections", window, cx)
5696            });
5697            panel.confirm_edit(window, cx).unwrap()
5698        })
5699        .await
5700        .unwrap();
5701    cx.run_until_parked();
5702    #[rustfmt::skip]
5703    assert_eq!(
5704        visible_entries_as_strings(&panel, 0..20, cx),
5705        &[
5706            "v root",
5707            "    > dir1",
5708            "      hello_from_no_selections  <== selected  <== marked",
5709        ],
5710        "A new file is created under the root directory"
5711    );
5712}
5713
5714#[gpui::test]
5715async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
5716    init_test(cx);
5717
5718    let fs = FakeFs::new(cx.executor());
5719    fs.insert_tree(
5720        path!("/root"),
5721        json!({
5722            "existing_dir": {
5723                "existing_file.txt": "",
5724            },
5725            "existing_file.txt": "",
5726        }),
5727    )
5728    .await;
5729
5730    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5731    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5732    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5733
5734    cx.update(|_, cx| {
5735        let settings = *ProjectPanelSettings::get_global(cx);
5736        ProjectPanelSettings::override_global(
5737            ProjectPanelSettings {
5738                hide_root: true,
5739                ..settings
5740            },
5741            cx,
5742        );
5743    });
5744
5745    let panel = workspace
5746        .update(cx, |workspace, window, cx| {
5747            let panel = ProjectPanel::new(workspace, window, cx);
5748            workspace.add_panel(panel.clone(), window, cx);
5749            panel
5750        })
5751        .unwrap();
5752    cx.run_until_parked();
5753
5754    #[rustfmt::skip]
5755    assert_eq!(
5756        visible_entries_as_strings(&panel, 0..20, cx),
5757        &[
5758            "> existing_dir",
5759            "  existing_file.txt",
5760        ],
5761        "Initial state with hide_root=true, root should be hidden and nothing selected"
5762    );
5763
5764    panel.update(cx, |panel, _| {
5765        assert!(
5766            panel.state.selection.is_none(),
5767            "Should have no selection initially"
5768        );
5769    });
5770
5771    // Test 1: Create new file when no entry is selected
5772    panel.update_in(cx, |panel, window, cx| {
5773        panel.new_file(&NewFile, window, cx);
5774    });
5775    cx.run_until_parked();
5776    panel.update_in(cx, |panel, window, cx| {
5777        assert!(panel.filename_editor.read(cx).is_focused(window));
5778    });
5779    cx.run_until_parked();
5780    #[rustfmt::skip]
5781    assert_eq!(
5782        visible_entries_as_strings(&panel, 0..20, cx),
5783        &[
5784            "> existing_dir",
5785            "  [EDITOR: '']  <== selected",
5786            "  existing_file.txt",
5787        ],
5788        "Editor should appear at root level when hide_root=true and no selection"
5789    );
5790
5791    let confirm = panel.update_in(cx, |panel, window, cx| {
5792        panel.filename_editor.update(cx, |editor, cx| {
5793            editor.set_text("new_file_at_root.txt", window, cx)
5794        });
5795        panel.confirm_edit(window, cx).unwrap()
5796    });
5797    confirm.await.unwrap();
5798    cx.run_until_parked();
5799
5800    #[rustfmt::skip]
5801    assert_eq!(
5802        visible_entries_as_strings(&panel, 0..20, cx),
5803        &[
5804            "> existing_dir",
5805            "  existing_file.txt",
5806            "  new_file_at_root.txt  <== selected  <== marked",
5807        ],
5808        "New file should be created at root level and visible without root prefix"
5809    );
5810
5811    assert!(
5812        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
5813        "File should be created in the actual root directory"
5814    );
5815
5816    // Test 2: Create new directory when no entry is selected
5817    panel.update(cx, |panel, _| {
5818        panel.state.selection = None;
5819    });
5820
5821    panel.update_in(cx, |panel, window, cx| {
5822        panel.new_directory(&NewDirectory, window, cx);
5823    });
5824    cx.run_until_parked();
5825
5826    panel.update_in(cx, |panel, window, cx| {
5827        assert!(panel.filename_editor.read(cx).is_focused(window));
5828    });
5829
5830    #[rustfmt::skip]
5831    assert_eq!(
5832        visible_entries_as_strings(&panel, 0..20, cx),
5833        &[
5834            "> [EDITOR: '']  <== selected",
5835            "> existing_dir",
5836            "  existing_file.txt",
5837            "  new_file_at_root.txt",
5838        ],
5839        "Directory editor should appear at root level when hide_root=true and no selection"
5840    );
5841
5842    let confirm = panel.update_in(cx, |panel, window, cx| {
5843        panel.filename_editor.update(cx, |editor, cx| {
5844            editor.set_text("new_dir_at_root", window, cx)
5845        });
5846        panel.confirm_edit(window, cx).unwrap()
5847    });
5848    confirm.await.unwrap();
5849    cx.run_until_parked();
5850
5851    #[rustfmt::skip]
5852    assert_eq!(
5853        visible_entries_as_strings(&panel, 0..20, cx),
5854        &[
5855            "> existing_dir",
5856            "v new_dir_at_root  <== selected",
5857            "  existing_file.txt",
5858            "  new_file_at_root.txt",
5859        ],
5860        "New directory should be created at root level and visible without root prefix"
5861    );
5862
5863    assert!(
5864        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
5865        "Directory should be created in the actual root directory"
5866    );
5867}
5868
5869#[gpui::test]
5870async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
5871    init_test(cx);
5872
5873    let fs = FakeFs::new(cx.executor());
5874    fs.insert_tree(
5875        "/root",
5876        json!({
5877            "dir1": {
5878                "file1.txt": "",
5879                "dir2": {
5880                    "file2.txt": ""
5881                }
5882            },
5883            "file3.txt": ""
5884        }),
5885    )
5886    .await;
5887
5888    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5889    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5890    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5891    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5892    cx.run_until_parked();
5893
5894    panel.update(cx, |panel, cx| {
5895        let project = panel.project.read(cx);
5896        let worktree = project.visible_worktrees(cx).next().unwrap();
5897        let worktree = worktree.read(cx);
5898
5899        // Test 1: Target is a directory, should highlight the directory itself
5900        let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
5901        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
5902        assert_eq!(
5903            result,
5904            Some(dir_entry.id),
5905            "Should highlight directory itself"
5906        );
5907
5908        // Test 2: Target is nested file, should highlight immediate parent
5909        let nested_file = worktree
5910            .entry_for_path(rel_path("dir1/dir2/file2.txt"))
5911            .unwrap();
5912        let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
5913        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
5914        assert_eq!(
5915            result,
5916            Some(nested_parent.id),
5917            "Should highlight immediate parent"
5918        );
5919
5920        // Test 3: Target is root level file, should highlight root
5921        let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
5922        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
5923        assert_eq!(
5924            result,
5925            Some(worktree.root_entry().unwrap().id),
5926            "Root level file should return None"
5927        );
5928
5929        // Test 4: Target is root itself, should highlight root
5930        let root_entry = worktree.root_entry().unwrap();
5931        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
5932        assert_eq!(
5933            result,
5934            Some(root_entry.id),
5935            "Root level file should return None"
5936        );
5937    });
5938}
5939
5940#[gpui::test]
5941async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
5942    init_test(cx);
5943
5944    let fs = FakeFs::new(cx.executor());
5945    fs.insert_tree(
5946        "/root",
5947        json!({
5948            "parent_dir": {
5949                "child_file.txt": "",
5950                "sibling_file.txt": "",
5951                "child_dir": {
5952                    "nested_file.txt": ""
5953                }
5954            },
5955            "other_dir": {
5956                "other_file.txt": ""
5957            }
5958        }),
5959    )
5960    .await;
5961
5962    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5963    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5964    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5965    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5966    cx.run_until_parked();
5967
5968    panel.update(cx, |panel, cx| {
5969        let project = panel.project.read(cx);
5970        let worktree = project.visible_worktrees(cx).next().unwrap();
5971        let worktree_id = worktree.read(cx).id();
5972        let worktree = worktree.read(cx);
5973
5974        let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
5975        let child_file = worktree
5976            .entry_for_path(rel_path("parent_dir/child_file.txt"))
5977            .unwrap();
5978        let sibling_file = worktree
5979            .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
5980            .unwrap();
5981        let child_dir = worktree
5982            .entry_for_path(rel_path("parent_dir/child_dir"))
5983            .unwrap();
5984        let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
5985        let other_file = worktree
5986            .entry_for_path(rel_path("other_dir/other_file.txt"))
5987            .unwrap();
5988
5989        // Test 1: Single item drag, don't highlight parent directory
5990        let dragged_selection = DraggedSelection {
5991            active_selection: SelectedEntry {
5992                worktree_id,
5993                entry_id: child_file.id,
5994            },
5995            marked_selections: Arc::new([SelectedEntry {
5996                worktree_id,
5997                entry_id: child_file.id,
5998            }]),
5999        };
6000        let result =
6001            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
6002        assert_eq!(result, None, "Should not highlight parent of dragged item");
6003
6004        // Test 2: Single item drag, don't highlight sibling files
6005        let result = panel.highlight_entry_for_selection_drag(
6006            sibling_file,
6007            worktree,
6008            &dragged_selection,
6009            cx,
6010        );
6011        assert_eq!(result, None, "Should not highlight sibling files");
6012
6013        // Test 3: Single item drag, highlight unrelated directory
6014        let result =
6015            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
6016        assert_eq!(
6017            result,
6018            Some(other_dir.id),
6019            "Should highlight unrelated directory"
6020        );
6021
6022        // Test 4: Single item drag, highlight sibling directory
6023        let result =
6024            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
6025        assert_eq!(
6026            result,
6027            Some(child_dir.id),
6028            "Should highlight sibling directory"
6029        );
6030
6031        // Test 5: Multiple items drag, highlight parent directory
6032        let dragged_selection = DraggedSelection {
6033            active_selection: SelectedEntry {
6034                worktree_id,
6035                entry_id: child_file.id,
6036            },
6037            marked_selections: Arc::new([
6038                SelectedEntry {
6039                    worktree_id,
6040                    entry_id: child_file.id,
6041                },
6042                SelectedEntry {
6043                    worktree_id,
6044                    entry_id: sibling_file.id,
6045                },
6046            ]),
6047        };
6048        let result =
6049            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
6050        assert_eq!(
6051            result,
6052            Some(parent_dir.id),
6053            "Should highlight parent with multiple items"
6054        );
6055
6056        // Test 6: Target is file in different directory, highlight parent
6057        let result =
6058            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
6059        assert_eq!(
6060            result,
6061            Some(other_dir.id),
6062            "Should highlight parent of target file"
6063        );
6064
6065        // Test 7: Target is directory, always highlight
6066        let result =
6067            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
6068        assert_eq!(
6069            result,
6070            Some(child_dir.id),
6071            "Should always highlight directories"
6072        );
6073    });
6074}
6075
6076#[gpui::test]
6077async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
6078    init_test(cx);
6079
6080    let fs = FakeFs::new(cx.executor());
6081    fs.insert_tree(
6082        "/root1",
6083        json!({
6084            "src": {
6085                "main.rs": "",
6086                "lib.rs": ""
6087            }
6088        }),
6089    )
6090    .await;
6091    fs.insert_tree(
6092        "/root2",
6093        json!({
6094            "src": {
6095                "main.rs": "",
6096                "test.rs": ""
6097            }
6098        }),
6099    )
6100    .await;
6101
6102    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6103    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6104    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6105    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6106    cx.run_until_parked();
6107
6108    panel.update(cx, |panel, cx| {
6109        let project = panel.project.read(cx);
6110        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
6111
6112        let worktree_a = &worktrees[0];
6113        let main_rs_from_a = worktree_a
6114            .read(cx)
6115            .entry_for_path(rel_path("src/main.rs"))
6116            .unwrap();
6117
6118        let worktree_b = &worktrees[1];
6119        let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
6120        let main_rs_from_b = worktree_b
6121            .read(cx)
6122            .entry_for_path(rel_path("src/main.rs"))
6123            .unwrap();
6124
6125        // Test dragging file from worktree A onto parent of file with same relative path in worktree B
6126        let dragged_selection = DraggedSelection {
6127            active_selection: SelectedEntry {
6128                worktree_id: worktree_a.read(cx).id(),
6129                entry_id: main_rs_from_a.id,
6130            },
6131            marked_selections: Arc::new([SelectedEntry {
6132                worktree_id: worktree_a.read(cx).id(),
6133                entry_id: main_rs_from_a.id,
6134            }]),
6135        };
6136
6137        let result = panel.highlight_entry_for_selection_drag(
6138            src_dir_from_b,
6139            worktree_b.read(cx),
6140            &dragged_selection,
6141            cx,
6142        );
6143        assert_eq!(
6144            result,
6145            Some(src_dir_from_b.id),
6146            "Should highlight target directory from different worktree even with same relative path"
6147        );
6148
6149        // Test dragging file from worktree A onto file with same relative path in worktree B
6150        let result = panel.highlight_entry_for_selection_drag(
6151            main_rs_from_b,
6152            worktree_b.read(cx),
6153            &dragged_selection,
6154            cx,
6155        );
6156        assert_eq!(
6157            result,
6158            Some(src_dir_from_b.id),
6159            "Should highlight parent of target file from different worktree"
6160        );
6161    });
6162}
6163
6164#[gpui::test]
6165async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
6166    init_test(cx);
6167
6168    let fs = FakeFs::new(cx.executor());
6169    fs.insert_tree(
6170        "/root1",
6171        json!({
6172            "parent_dir": {
6173                "child_file.txt": "",
6174                "nested_dir": {
6175                    "nested_file.txt": ""
6176                }
6177            },
6178            "root_file.txt": ""
6179        }),
6180    )
6181    .await;
6182
6183    fs.insert_tree(
6184        "/root2",
6185        json!({
6186            "other_dir": {
6187                "other_file.txt": ""
6188            }
6189        }),
6190    )
6191    .await;
6192
6193    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6194    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6195    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6196    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6197    cx.run_until_parked();
6198
6199    panel.update(cx, |panel, cx| {
6200        let project = panel.project.read(cx);
6201        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
6202        let worktree1 = worktrees[0].read(cx);
6203        let worktree2 = worktrees[1].read(cx);
6204        let worktree1_id = worktree1.id();
6205        let _worktree2_id = worktree2.id();
6206
6207        let root1_entry = worktree1.root_entry().unwrap();
6208        let root2_entry = worktree2.root_entry().unwrap();
6209        let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
6210        let child_file = worktree1
6211            .entry_for_path(rel_path("parent_dir/child_file.txt"))
6212            .unwrap();
6213        let nested_file = worktree1
6214            .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
6215            .unwrap();
6216        let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
6217
6218        // Test 1: Multiple entries - should always highlight background
6219        let multiple_dragged_selection = DraggedSelection {
6220            active_selection: SelectedEntry {
6221                worktree_id: worktree1_id,
6222                entry_id: child_file.id,
6223            },
6224            marked_selections: Arc::new([
6225                SelectedEntry {
6226                    worktree_id: worktree1_id,
6227                    entry_id: child_file.id,
6228                },
6229                SelectedEntry {
6230                    worktree_id: worktree1_id,
6231                    entry_id: nested_file.id,
6232                },
6233            ]),
6234        };
6235
6236        let result = panel.should_highlight_background_for_selection_drag(
6237            &multiple_dragged_selection,
6238            root1_entry.id,
6239            cx,
6240        );
6241        assert!(result, "Should highlight background for multiple entries");
6242
6243        // Test 2: Single entry with non-empty parent path - should highlight background
6244        let nested_dragged_selection = DraggedSelection {
6245            active_selection: SelectedEntry {
6246                worktree_id: worktree1_id,
6247                entry_id: nested_file.id,
6248            },
6249            marked_selections: Arc::new([SelectedEntry {
6250                worktree_id: worktree1_id,
6251                entry_id: nested_file.id,
6252            }]),
6253        };
6254
6255        let result = panel.should_highlight_background_for_selection_drag(
6256            &nested_dragged_selection,
6257            root1_entry.id,
6258            cx,
6259        );
6260        assert!(result, "Should highlight background for nested file");
6261
6262        // Test 3: Single entry at root level, same worktree - should NOT highlight background
6263        let root_file_dragged_selection = DraggedSelection {
6264            active_selection: SelectedEntry {
6265                worktree_id: worktree1_id,
6266                entry_id: root_file.id,
6267            },
6268            marked_selections: Arc::new([SelectedEntry {
6269                worktree_id: worktree1_id,
6270                entry_id: root_file.id,
6271            }]),
6272        };
6273
6274        let result = panel.should_highlight_background_for_selection_drag(
6275            &root_file_dragged_selection,
6276            root1_entry.id,
6277            cx,
6278        );
6279        assert!(
6280            !result,
6281            "Should NOT highlight background for root file in same worktree"
6282        );
6283
6284        // Test 4: Single entry at root level, different worktree - should highlight background
6285        let result = panel.should_highlight_background_for_selection_drag(
6286            &root_file_dragged_selection,
6287            root2_entry.id,
6288            cx,
6289        );
6290        assert!(
6291            result,
6292            "Should highlight background for root file from different worktree"
6293        );
6294
6295        // Test 5: Single entry in subdirectory - should highlight background
6296        let child_file_dragged_selection = DraggedSelection {
6297            active_selection: SelectedEntry {
6298                worktree_id: worktree1_id,
6299                entry_id: child_file.id,
6300            },
6301            marked_selections: Arc::new([SelectedEntry {
6302                worktree_id: worktree1_id,
6303                entry_id: child_file.id,
6304            }]),
6305        };
6306
6307        let result = panel.should_highlight_background_for_selection_drag(
6308            &child_file_dragged_selection,
6309            root1_entry.id,
6310            cx,
6311        );
6312        assert!(
6313            result,
6314            "Should highlight background for file with non-empty parent path"
6315        );
6316    });
6317}
6318
6319#[gpui::test]
6320async fn test_hide_root(cx: &mut gpui::TestAppContext) {
6321    init_test(cx);
6322
6323    let fs = FakeFs::new(cx.executor());
6324    fs.insert_tree(
6325        "/root1",
6326        json!({
6327            "dir1": {
6328                "file1.txt": "content",
6329                "file2.txt": "content",
6330            },
6331            "dir2": {
6332                "file3.txt": "content",
6333            },
6334            "file4.txt": "content",
6335        }),
6336    )
6337    .await;
6338
6339    fs.insert_tree(
6340        "/root2",
6341        json!({
6342            "dir3": {
6343                "file5.txt": "content",
6344            },
6345            "file6.txt": "content",
6346        }),
6347    )
6348    .await;
6349
6350    // Test 1: Single worktree with hide_root = false
6351    {
6352        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6353        let workspace =
6354            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6355        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6356
6357        cx.update(|_, cx| {
6358            let settings = *ProjectPanelSettings::get_global(cx);
6359            ProjectPanelSettings::override_global(
6360                ProjectPanelSettings {
6361                    hide_root: false,
6362                    ..settings
6363                },
6364                cx,
6365            );
6366        });
6367
6368        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6369        cx.run_until_parked();
6370
6371        #[rustfmt::skip]
6372        assert_eq!(
6373            visible_entries_as_strings(&panel, 0..10, cx),
6374            &[
6375                "v root1",
6376                "    > dir1",
6377                "    > dir2",
6378                "      file4.txt",
6379            ],
6380            "With hide_root=false and single worktree, root should be visible"
6381        );
6382    }
6383
6384    // Test 2: Single worktree with hide_root = true
6385    {
6386        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6387        let workspace =
6388            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6389        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6390
6391        // Set hide_root to true
6392        cx.update(|_, cx| {
6393            let settings = *ProjectPanelSettings::get_global(cx);
6394            ProjectPanelSettings::override_global(
6395                ProjectPanelSettings {
6396                    hide_root: true,
6397                    ..settings
6398                },
6399                cx,
6400            );
6401        });
6402
6403        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6404        cx.run_until_parked();
6405
6406        assert_eq!(
6407            visible_entries_as_strings(&panel, 0..10, cx),
6408            &["> dir1", "> dir2", "  file4.txt",],
6409            "With hide_root=true and single worktree, root should be hidden"
6410        );
6411
6412        // Test expanding directories still works without root
6413        toggle_expand_dir(&panel, "root1/dir1", cx);
6414        assert_eq!(
6415            visible_entries_as_strings(&panel, 0..10, cx),
6416            &[
6417                "v dir1  <== selected",
6418                "      file1.txt",
6419                "      file2.txt",
6420                "> dir2",
6421                "  file4.txt",
6422            ],
6423            "Should be able to expand directories even when root is hidden"
6424        );
6425    }
6426
6427    // Test 3: Multiple worktrees with hide_root = true
6428    {
6429        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6430        let workspace =
6431            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6432        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6433
6434        // Set hide_root to true
6435        cx.update(|_, cx| {
6436            let settings = *ProjectPanelSettings::get_global(cx);
6437            ProjectPanelSettings::override_global(
6438                ProjectPanelSettings {
6439                    hide_root: true,
6440                    ..settings
6441                },
6442                cx,
6443            );
6444        });
6445
6446        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6447        cx.run_until_parked();
6448
6449        assert_eq!(
6450            visible_entries_as_strings(&panel, 0..10, cx),
6451            &[
6452                "v root1",
6453                "    > dir1",
6454                "    > dir2",
6455                "      file4.txt",
6456                "v root2",
6457                "    > dir3",
6458                "      file6.txt",
6459            ],
6460            "With hide_root=true and multiple worktrees, roots should still be visible"
6461        );
6462    }
6463
6464    // Test 4: Multiple worktrees with hide_root = false
6465    {
6466        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6467        let workspace =
6468            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6469        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6470
6471        cx.update(|_, cx| {
6472            let settings = *ProjectPanelSettings::get_global(cx);
6473            ProjectPanelSettings::override_global(
6474                ProjectPanelSettings {
6475                    hide_root: false,
6476                    ..settings
6477                },
6478                cx,
6479            );
6480        });
6481
6482        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6483        cx.run_until_parked();
6484
6485        assert_eq!(
6486            visible_entries_as_strings(&panel, 0..10, cx),
6487            &[
6488                "v root1",
6489                "    > dir1",
6490                "    > dir2",
6491                "      file4.txt",
6492                "v root2",
6493                "    > dir3",
6494                "      file6.txt",
6495            ],
6496            "With hide_root=false and multiple worktrees, roots should be visible"
6497        );
6498    }
6499}
6500
6501#[gpui::test]
6502async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
6503    init_test_with_editor(cx);
6504
6505    let fs = FakeFs::new(cx.executor());
6506    fs.insert_tree(
6507        "/root",
6508        json!({
6509            "file1.txt": "content of file1",
6510            "file2.txt": "content of file2",
6511            "dir1": {
6512                "file3.txt": "content of file3"
6513            }
6514        }),
6515    )
6516    .await;
6517
6518    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6519    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6520    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6521    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6522    cx.run_until_parked();
6523
6524    let file1_path = "root/file1.txt";
6525    let file2_path = "root/file2.txt";
6526    select_path_with_mark(&panel, file1_path, cx);
6527    select_path_with_mark(&panel, file2_path, cx);
6528
6529    panel.update_in(cx, |panel, window, cx| {
6530        panel.compare_marked_files(&CompareMarkedFiles, window, cx);
6531    });
6532    cx.executor().run_until_parked();
6533
6534    workspace
6535        .update(cx, |workspace, _, cx| {
6536            let active_items = workspace
6537                .panes()
6538                .iter()
6539                .filter_map(|pane| pane.read(cx).active_item())
6540                .collect::<Vec<_>>();
6541            assert_eq!(active_items.len(), 1);
6542            let diff_view = active_items
6543                .into_iter()
6544                .next()
6545                .unwrap()
6546                .downcast::<FileDiffView>()
6547                .expect("Open item should be an FileDiffView");
6548            assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
6549            assert_eq!(
6550                diff_view.tab_tooltip_text(cx).unwrap(),
6551                format!(
6552                    "{}{}",
6553                    rel_path(file1_path).display(PathStyle::local()),
6554                    rel_path(file2_path).display(PathStyle::local())
6555                )
6556            );
6557        })
6558        .unwrap();
6559
6560    let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
6561    let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
6562    let worktree_id = panel.update(cx, |panel, cx| {
6563        panel
6564            .project
6565            .read(cx)
6566            .worktrees(cx)
6567            .next()
6568            .unwrap()
6569            .read(cx)
6570            .id()
6571    });
6572
6573    let expected_entries = [
6574        SelectedEntry {
6575            worktree_id,
6576            entry_id: file1_entry_id,
6577        },
6578        SelectedEntry {
6579            worktree_id,
6580            entry_id: file2_entry_id,
6581        },
6582    ];
6583    panel.update(cx, |panel, _cx| {
6584        assert_eq!(
6585            &panel.marked_entries, &expected_entries,
6586            "Should keep marked entries after comparison"
6587        );
6588    });
6589
6590    panel.update(cx, |panel, cx| {
6591        panel.project.update(cx, |_, cx| {
6592            cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
6593        })
6594    });
6595
6596    panel.update(cx, |panel, _cx| {
6597        assert_eq!(
6598            &panel.marked_entries, &expected_entries,
6599            "Marked entries should persist after focusing back on the project panel"
6600        );
6601    });
6602}
6603
6604#[gpui::test]
6605async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
6606    init_test_with_editor(cx);
6607
6608    let fs = FakeFs::new(cx.executor());
6609    fs.insert_tree(
6610        "/root",
6611        json!({
6612            "file1.txt": "content of file1",
6613            "file2.txt": "content of file2",
6614            "dir1": {},
6615            "dir2": {
6616                "file3.txt": "content of file3"
6617            }
6618        }),
6619    )
6620    .await;
6621
6622    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6623    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6624    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6625    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6626    cx.run_until_parked();
6627
6628    // Test 1: When only one file is selected, there should be no compare option
6629    select_path(&panel, "root/file1.txt", cx);
6630
6631    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6632    assert_eq!(
6633        selected_files, None,
6634        "Should not have compare option when only one file is selected"
6635    );
6636
6637    // Test 2: When multiple files are selected, there should be a compare option
6638    select_path_with_mark(&panel, "root/file1.txt", cx);
6639    select_path_with_mark(&panel, "root/file2.txt", cx);
6640
6641    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6642    assert!(
6643        selected_files.is_some(),
6644        "Should have files selected for comparison"
6645    );
6646    if let Some((file1, file2)) = selected_files {
6647        assert!(
6648            file1.to_string_lossy().ends_with("file1.txt")
6649                && file2.to_string_lossy().ends_with("file2.txt"),
6650            "Should have file1.txt and file2.txt as the selected files when multi-selecting"
6651        );
6652    }
6653
6654    // Test 3: Selecting a directory shouldn't count as a comparable file
6655    select_path_with_mark(&panel, "root/dir1", cx);
6656
6657    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6658    assert!(
6659        selected_files.is_some(),
6660        "Directory selection should not affect comparable files"
6661    );
6662    if let Some((file1, file2)) = selected_files {
6663        assert!(
6664            file1.to_string_lossy().ends_with("file1.txt")
6665                && file2.to_string_lossy().ends_with("file2.txt"),
6666            "Selecting a directory should not affect the number of comparable files"
6667        );
6668    }
6669
6670    // Test 4: Selecting one more file
6671    select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
6672
6673    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6674    assert!(
6675        selected_files.is_some(),
6676        "Directory selection should not affect comparable files"
6677    );
6678    if let Some((file1, file2)) = selected_files {
6679        assert!(
6680            file1.to_string_lossy().ends_with("file2.txt")
6681                && file2.to_string_lossy().ends_with("file3.txt"),
6682            "Selecting a directory should not affect the number of comparable files"
6683        );
6684    }
6685}
6686
6687#[gpui::test]
6688async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
6689    init_test(cx);
6690
6691    let fs = FakeFs::new(cx.executor());
6692    fs.insert_tree(
6693        "/root",
6694        json!({
6695            ".hidden-file.txt": "hidden file content",
6696            "visible-file.txt": "visible file content",
6697            ".hidden-parent-dir": {
6698                "nested-dir": {
6699                    "file.txt": "file content",
6700                }
6701            },
6702            "visible-dir": {
6703                "file-in-visible.txt": "file content",
6704                "nested": {
6705                    ".hidden-nested-dir": {
6706                        ".double-hidden-dir": {
6707                            "deep-file-1.txt": "deep content 1",
6708                            "deep-file-2.txt": "deep content 2"
6709                        },
6710                        "hidden-nested-file-1.txt": "hidden nested 1",
6711                        "hidden-nested-file-2.txt": "hidden nested 2"
6712                    },
6713                    "visible-nested-file.txt": "visible nested content"
6714                }
6715            }
6716        }),
6717    )
6718    .await;
6719
6720    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6721    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6722    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6723
6724    cx.update(|_, cx| {
6725        let settings = *ProjectPanelSettings::get_global(cx);
6726        ProjectPanelSettings::override_global(
6727            ProjectPanelSettings {
6728                hide_hidden: false,
6729                ..settings
6730            },
6731            cx,
6732        );
6733    });
6734
6735    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6736    cx.run_until_parked();
6737
6738    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
6739    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
6740    toggle_expand_dir(&panel, "root/visible-dir", cx);
6741    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
6742    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
6743    toggle_expand_dir(
6744        &panel,
6745        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
6746        cx,
6747    );
6748
6749    let expanded = [
6750        "v root",
6751        "    v .hidden-parent-dir",
6752        "        v nested-dir",
6753        "              file.txt",
6754        "    v visible-dir",
6755        "        v nested",
6756        "            v .hidden-nested-dir",
6757        "                v .double-hidden-dir  <== selected",
6758        "                      deep-file-1.txt",
6759        "                      deep-file-2.txt",
6760        "                  hidden-nested-file-1.txt",
6761        "                  hidden-nested-file-2.txt",
6762        "              visible-nested-file.txt",
6763        "          file-in-visible.txt",
6764        "      .hidden-file.txt",
6765        "      visible-file.txt",
6766    ];
6767
6768    assert_eq!(
6769        visible_entries_as_strings(&panel, 0..30, cx),
6770        &expanded,
6771        "With hide_hidden=false, contents of hidden nested directory should be visible"
6772    );
6773
6774    cx.update(|_, cx| {
6775        let settings = *ProjectPanelSettings::get_global(cx);
6776        ProjectPanelSettings::override_global(
6777            ProjectPanelSettings {
6778                hide_hidden: true,
6779                ..settings
6780            },
6781            cx,
6782        );
6783    });
6784
6785    panel.update_in(cx, |panel, window, cx| {
6786        panel.update_visible_entries(None, false, false, window, cx);
6787    });
6788    cx.run_until_parked();
6789
6790    assert_eq!(
6791        visible_entries_as_strings(&panel, 0..30, cx),
6792        &[
6793            "v root",
6794            "    v visible-dir",
6795            "        v nested",
6796            "              visible-nested-file.txt",
6797            "          file-in-visible.txt",
6798            "      visible-file.txt",
6799        ],
6800        "With hide_hidden=false, contents of hidden nested directory should be visible"
6801    );
6802
6803    panel.update_in(cx, |panel, window, cx| {
6804        let settings = *ProjectPanelSettings::get_global(cx);
6805        ProjectPanelSettings::override_global(
6806            ProjectPanelSettings {
6807                hide_hidden: false,
6808                ..settings
6809            },
6810            cx,
6811        );
6812        panel.update_visible_entries(None, false, false, window, cx);
6813    });
6814    cx.run_until_parked();
6815
6816    assert_eq!(
6817        visible_entries_as_strings(&panel, 0..30, cx),
6818        &expanded,
6819        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
6820    );
6821}
6822
6823fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6824    let path = rel_path(path);
6825    panel.update_in(cx, |panel, window, cx| {
6826        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6827            let worktree = worktree.read(cx);
6828            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6829                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6830                panel.update_visible_entries(
6831                    Some((worktree.id(), entry_id)),
6832                    false,
6833                    false,
6834                    window,
6835                    cx,
6836                );
6837                return;
6838            }
6839        }
6840        panic!("no worktree for path {:?}", path);
6841    });
6842    cx.run_until_parked();
6843}
6844
6845fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6846    let path = rel_path(path);
6847    panel.update(cx, |panel, cx| {
6848        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6849            let worktree = worktree.read(cx);
6850            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6851                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6852                let entry = crate::SelectedEntry {
6853                    worktree_id: worktree.id(),
6854                    entry_id,
6855                };
6856                if !panel.marked_entries.contains(&entry) {
6857                    panel.marked_entries.push(entry);
6858                }
6859                panel.state.selection = Some(entry);
6860                return;
6861            }
6862        }
6863        panic!("no worktree for path {:?}", path);
6864    });
6865}
6866
6867fn find_project_entry(
6868    panel: &Entity<ProjectPanel>,
6869    path: &str,
6870    cx: &mut VisualTestContext,
6871) -> Option<ProjectEntryId> {
6872    let path = rel_path(path);
6873    panel.update(cx, |panel, cx| {
6874        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6875            let worktree = worktree.read(cx);
6876            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6877                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6878            }
6879        }
6880        panic!("no worktree for path {path:?}");
6881    })
6882}
6883
6884fn visible_entries_as_strings(
6885    panel: &Entity<ProjectPanel>,
6886    range: Range<usize>,
6887    cx: &mut VisualTestContext,
6888) -> Vec<String> {
6889    let mut result = Vec::new();
6890    let mut project_entries = HashSet::default();
6891    let mut has_editor = false;
6892
6893    panel.update_in(cx, |panel, window, cx| {
6894        panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
6895            if details.is_editing {
6896                assert!(!has_editor, "duplicate editor entry");
6897                has_editor = true;
6898            } else {
6899                assert!(
6900                    project_entries.insert(project_entry),
6901                    "duplicate project entry {:?} {:?}",
6902                    project_entry,
6903                    details
6904                );
6905            }
6906
6907            let indent = "    ".repeat(details.depth);
6908            let icon = if details.kind.is_dir() {
6909                if details.is_expanded { "v " } else { "> " }
6910            } else {
6911                "  "
6912            };
6913            #[cfg(windows)]
6914            let filename = details.filename.replace("\\", "/");
6915            #[cfg(not(windows))]
6916            let filename = details.filename;
6917            let name = if details.is_editing {
6918                format!("[EDITOR: '{}']", filename)
6919            } else if details.is_processing {
6920                format!("[PROCESSING: '{}']", filename)
6921            } else {
6922                filename
6923            };
6924            let selected = if details.is_selected {
6925                "  <== selected"
6926            } else {
6927                ""
6928            };
6929            let marked = if details.is_marked {
6930                "  <== marked"
6931            } else {
6932                ""
6933            };
6934
6935            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6936        });
6937    });
6938
6939    result
6940}
6941
6942fn init_test(cx: &mut TestAppContext) {
6943    cx.update(|cx| {
6944        let settings_store = SettingsStore::test(cx);
6945        cx.set_global(settings_store);
6946        init_settings(cx);
6947        theme::init(theme::LoadThemes::JustBase, cx);
6948        language::init(cx);
6949        editor::init_settings(cx);
6950        crate::init(cx);
6951        workspace::init_settings(cx);
6952        client::init_settings(cx);
6953        Project::init_settings(cx);
6954
6955        cx.update_global::<SettingsStore, _>(|store, cx| {
6956            store.update_user_settings(cx, |settings| {
6957                settings
6958                    .project_panel
6959                    .get_or_insert_default()
6960                    .auto_fold_dirs = Some(false);
6961                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
6962            });
6963        });
6964    });
6965}
6966
6967fn init_test_with_editor(cx: &mut TestAppContext) {
6968    cx.update(|cx| {
6969        let app_state = AppState::test(cx);
6970        theme::init(theme::LoadThemes::JustBase, cx);
6971        init_settings(cx);
6972        language::init(cx);
6973        editor::init(cx);
6974        crate::init(cx);
6975        workspace::init(app_state, cx);
6976        Project::init_settings(cx);
6977
6978        cx.update_global::<SettingsStore, _>(|store, cx| {
6979            store.update_user_settings(cx, |settings| {
6980                settings
6981                    .project_panel
6982                    .get_or_insert_default()
6983                    .auto_fold_dirs = Some(false);
6984                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
6985            });
6986        });
6987    });
6988}
6989
6990fn ensure_single_file_is_opened(
6991    window: &WindowHandle<Workspace>,
6992    expected_path: &str,
6993    cx: &mut TestAppContext,
6994) {
6995    window
6996        .update(cx, |workspace, _, cx| {
6997            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6998            assert_eq!(worktrees.len(), 1);
6999            let worktree_id = worktrees[0].read(cx).id();
7000
7001            let open_project_paths = workspace
7002                .panes()
7003                .iter()
7004                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7005                .collect::<Vec<_>>();
7006            assert_eq!(
7007                open_project_paths,
7008                vec![ProjectPath {
7009                    worktree_id,
7010                    path: Arc::from(rel_path(expected_path))
7011                }],
7012                "Should have opened file, selected in project panel"
7013            );
7014        })
7015        .unwrap();
7016}
7017
7018fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
7019    assert!(
7020        !cx.has_pending_prompt(),
7021        "Should have no prompts before the deletion"
7022    );
7023    panel.update_in(cx, |panel, window, cx| {
7024        panel.delete(&Delete { skip_prompt: false }, window, cx)
7025    });
7026    assert!(
7027        cx.has_pending_prompt(),
7028        "Should have a prompt after the deletion"
7029    );
7030    cx.simulate_prompt_answer("Delete");
7031    assert!(
7032        !cx.has_pending_prompt(),
7033        "Should have no prompts after prompt was replied to"
7034    );
7035    cx.executor().run_until_parked();
7036}
7037
7038fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
7039    assert!(
7040        !cx.has_pending_prompt(),
7041        "Should have no prompts before the deletion"
7042    );
7043    panel.update_in(cx, |panel, window, cx| {
7044        panel.delete(&Delete { skip_prompt: true }, window, cx)
7045    });
7046    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
7047    cx.executor().run_until_parked();
7048}
7049
7050fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
7051    assert!(
7052        !cx.has_pending_prompt(),
7053        "Should have no prompts after deletion operation closes the file"
7054    );
7055    workspace
7056        .read_with(cx, |workspace, cx| {
7057            let open_project_paths = workspace
7058                .panes()
7059                .iter()
7060                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7061                .collect::<Vec<_>>();
7062            assert!(
7063                open_project_paths.is_empty(),
7064                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
7065            );
7066        })
7067        .unwrap();
7068}
7069
7070struct TestProjectItemView {
7071    focus_handle: FocusHandle,
7072    path: ProjectPath,
7073}
7074
7075struct TestProjectItem {
7076    path: ProjectPath,
7077}
7078
7079impl project::ProjectItem for TestProjectItem {
7080    fn try_open(
7081        _project: &Entity<Project>,
7082        path: &ProjectPath,
7083        cx: &mut App,
7084    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
7085        let path = path.clone();
7086        Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
7087    }
7088
7089    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
7090        None
7091    }
7092
7093    fn project_path(&self, _: &App) -> Option<ProjectPath> {
7094        Some(self.path.clone())
7095    }
7096
7097    fn is_dirty(&self) -> bool {
7098        false
7099    }
7100}
7101
7102impl ProjectItem for TestProjectItemView {
7103    type Item = TestProjectItem;
7104
7105    fn for_project_item(
7106        _: Entity<Project>,
7107        _: Option<&Pane>,
7108        project_item: Entity<Self::Item>,
7109        _: &mut Window,
7110        cx: &mut Context<Self>,
7111    ) -> Self
7112    where
7113        Self: Sized,
7114    {
7115        Self {
7116            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
7117            focus_handle: cx.focus_handle(),
7118        }
7119    }
7120}
7121
7122impl Item for TestProjectItemView {
7123    type Event = ();
7124
7125    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
7126        "Test".into()
7127    }
7128}
7129
7130impl EventEmitter<()> for TestProjectItemView {}
7131
7132impl Focusable for TestProjectItemView {
7133    fn focus_handle(&self, _: &App) -> FocusHandle {
7134        self.focus_handle.clone()
7135    }
7136}
7137
7138impl Render for TestProjectItemView {
7139    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
7140        Empty
7141    }
7142}