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#[gpui::test]
2987#[cfg_attr(target_os = "windows", ignore)]
2988async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2989    init_test_with_editor(cx);
2990
2991    let fs = FakeFs::new(cx.executor());
2992    fs.insert_tree(
2993        "/root1",
2994        json!({
2995            "dir1": {
2996                "file1.txt": "content 1",
2997            },
2998        }),
2999    )
3000    .await;
3001
3002    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3003    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3004    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3005    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3006    cx.run_until_parked();
3007
3008    toggle_expand_dir(&panel, "root1/dir1", cx);
3009
3010    assert_eq!(
3011        visible_entries_as_strings(&panel, 0..20, cx),
3012        &["v root1", "    v dir1  <== selected", "          file1.txt",],
3013        "Initial state with worktrees"
3014    );
3015
3016    select_path(&panel, "root1", cx);
3017    assert_eq!(
3018        visible_entries_as_strings(&panel, 0..20, cx),
3019        &["v root1  <== selected", "    v dir1", "          file1.txt",],
3020    );
3021
3022    // Rename root1 to new_root1
3023    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3024
3025    assert_eq!(
3026        visible_entries_as_strings(&panel, 0..20, cx),
3027        &[
3028            "v [EDITOR: 'root1']  <== selected",
3029            "    v dir1",
3030            "          file1.txt",
3031        ],
3032    );
3033
3034    let confirm = panel.update_in(cx, |panel, window, cx| {
3035        panel
3036            .filename_editor
3037            .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3038        panel.confirm_edit(window, cx).unwrap()
3039    });
3040    confirm.await.unwrap();
3041    cx.run_until_parked();
3042    assert_eq!(
3043        visible_entries_as_strings(&panel, 0..20, cx),
3044        &[
3045            "v new_root1  <== selected",
3046            "    v dir1",
3047            "          file1.txt",
3048        ],
3049        "Should update worktree name"
3050    );
3051
3052    // Ensure internal paths have been updated
3053    select_path(&panel, "new_root1/dir1/file1.txt", cx);
3054    assert_eq!(
3055        visible_entries_as_strings(&panel, 0..20, cx),
3056        &[
3057            "v new_root1",
3058            "    v dir1",
3059            "          file1.txt  <== selected",
3060        ],
3061        "Files in renamed worktree are selectable"
3062    );
3063}
3064
3065#[gpui::test]
3066async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3067    init_test_with_editor(cx);
3068
3069    let fs = FakeFs::new(cx.executor());
3070    fs.insert_tree(
3071        "/root1",
3072        json!({
3073            "dir1": { "file1.txt": "content" },
3074            "file2.txt": "content",
3075        }),
3076    )
3077    .await;
3078    fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3079        .await;
3080
3081    // Test 1: Single worktree, hide_root=true - rename should be blocked
3082    {
3083        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3084        let workspace =
3085            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3086        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3087
3088        cx.update(|_, cx| {
3089            let settings = *ProjectPanelSettings::get_global(cx);
3090            ProjectPanelSettings::override_global(
3091                ProjectPanelSettings {
3092                    hide_root: true,
3093                    ..settings
3094                },
3095                cx,
3096            );
3097        });
3098
3099        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3100        cx.run_until_parked();
3101
3102        panel.update(cx, |panel, cx| {
3103            let project = panel.project.read(cx);
3104            let worktree = project.visible_worktrees(cx).next().unwrap();
3105            let root_entry = worktree.read(cx).root_entry().unwrap();
3106            panel.state.selection = Some(SelectedEntry {
3107                worktree_id: worktree.read(cx).id(),
3108                entry_id: root_entry.id,
3109            });
3110        });
3111
3112        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3113
3114        assert!(
3115            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3116            "Rename should be blocked when hide_root=true with single worktree"
3117        );
3118    }
3119
3120    // Test 2: Multiple worktrees, hide_root=true - rename should work
3121    {
3122        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3123        let workspace =
3124            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3125        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3126
3127        cx.update(|_, cx| {
3128            let settings = *ProjectPanelSettings::get_global(cx);
3129            ProjectPanelSettings::override_global(
3130                ProjectPanelSettings {
3131                    hide_root: true,
3132                    ..settings
3133                },
3134                cx,
3135            );
3136        });
3137
3138        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3139        cx.run_until_parked();
3140
3141        select_path(&panel, "root1", cx);
3142        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3143
3144        #[cfg(target_os = "windows")]
3145        assert!(
3146            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3147            "Rename should be blocked on Windows even with multiple worktrees"
3148        );
3149
3150        #[cfg(not(target_os = "windows"))]
3151        {
3152            assert!(
3153                panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
3154                "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3155            );
3156            panel.update_in(cx, |panel, window, cx| {
3157                panel.cancel(&menu::Cancel, window, cx)
3158            });
3159        }
3160    }
3161}
3162
3163#[gpui::test]
3164async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3165    init_test_with_editor(cx);
3166    let fs = FakeFs::new(cx.executor());
3167    fs.insert_tree(
3168        "/project_root",
3169        json!({
3170            "dir_1": {
3171                "nested_dir": {
3172                    "file_a.py": "# File contents",
3173                }
3174            },
3175            "file_1.py": "# File contents",
3176        }),
3177    )
3178    .await;
3179
3180    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3181    let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3182    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3183    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3184    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3185    cx.run_until_parked();
3186
3187    cx.update(|window, cx| {
3188        panel.update(cx, |this, cx| {
3189            this.select_next(&Default::default(), window, cx);
3190            this.expand_selected_entry(&Default::default(), window, cx);
3191        })
3192    });
3193    cx.run_until_parked();
3194
3195    cx.update(|window, cx| {
3196        panel.update(cx, |this, cx| {
3197            this.expand_selected_entry(&Default::default(), window, cx);
3198        })
3199    });
3200    cx.run_until_parked();
3201
3202    cx.update(|window, cx| {
3203        panel.update(cx, |this, cx| {
3204            this.select_next(&Default::default(), window, cx);
3205            this.expand_selected_entry(&Default::default(), window, cx);
3206        })
3207    });
3208    cx.run_until_parked();
3209
3210    cx.update(|window, cx| {
3211        panel.update(cx, |this, cx| {
3212            this.select_next(&Default::default(), window, cx);
3213        })
3214    });
3215    cx.run_until_parked();
3216
3217    assert_eq!(
3218        visible_entries_as_strings(&panel, 0..10, cx),
3219        &[
3220            "v project_root",
3221            "    v dir_1",
3222            "        v nested_dir",
3223            "              file_a.py  <== selected",
3224            "      file_1.py",
3225        ]
3226    );
3227    let modifiers_with_shift = gpui::Modifiers {
3228        shift: true,
3229        ..Default::default()
3230    };
3231    cx.run_until_parked();
3232    cx.simulate_modifiers_change(modifiers_with_shift);
3233    cx.update(|window, cx| {
3234        panel.update(cx, |this, cx| {
3235            this.select_next(&Default::default(), window, cx);
3236        })
3237    });
3238    assert_eq!(
3239        visible_entries_as_strings(&panel, 0..10, cx),
3240        &[
3241            "v project_root",
3242            "    v dir_1",
3243            "        v nested_dir",
3244            "              file_a.py",
3245            "      file_1.py  <== selected  <== marked",
3246        ]
3247    );
3248    cx.update(|window, cx| {
3249        panel.update(cx, |this, cx| {
3250            this.select_previous(&Default::default(), window, cx);
3251        })
3252    });
3253    assert_eq!(
3254        visible_entries_as_strings(&panel, 0..10, cx),
3255        &[
3256            "v project_root",
3257            "    v dir_1",
3258            "        v nested_dir",
3259            "              file_a.py  <== selected  <== marked",
3260            "      file_1.py  <== marked",
3261        ]
3262    );
3263    cx.update(|window, cx| {
3264        panel.update(cx, |this, cx| {
3265            let drag = DraggedSelection {
3266                active_selection: this.state.selection.unwrap(),
3267                marked_selections: this.marked_entries.clone().into(),
3268            };
3269            let target_entry = this
3270                .project
3271                .read(cx)
3272                .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
3273                .unwrap();
3274            this.drag_onto(&drag, target_entry.id, false, window, cx);
3275        });
3276    });
3277    cx.run_until_parked();
3278    assert_eq!(
3279        visible_entries_as_strings(&panel, 0..10, cx),
3280        &[
3281            "v project_root",
3282            "    v dir_1",
3283            "        v nested_dir",
3284            "      file_1.py  <== marked",
3285            "      file_a.py  <== selected  <== marked",
3286        ]
3287    );
3288    // ESC clears out all marks
3289    cx.update(|window, cx| {
3290        panel.update(cx, |this, cx| {
3291            this.cancel(&menu::Cancel, window, cx);
3292        })
3293    });
3294    assert_eq!(
3295        visible_entries_as_strings(&panel, 0..10, cx),
3296        &[
3297            "v project_root",
3298            "    v dir_1",
3299            "        v nested_dir",
3300            "      file_1.py",
3301            "      file_a.py  <== selected",
3302        ]
3303    );
3304    // ESC clears out all marks
3305    cx.update(|window, cx| {
3306        panel.update(cx, |this, cx| {
3307            this.select_previous(&SelectPrevious, window, cx);
3308            this.select_next(&SelectNext, window, cx);
3309        })
3310    });
3311    assert_eq!(
3312        visible_entries_as_strings(&panel, 0..10, cx),
3313        &[
3314            "v project_root",
3315            "    v dir_1",
3316            "        v nested_dir",
3317            "      file_1.py  <== marked",
3318            "      file_a.py  <== selected  <== marked",
3319        ]
3320    );
3321    cx.simulate_modifiers_change(Default::default());
3322    cx.update(|window, cx| {
3323        panel.update(cx, |this, cx| {
3324            this.cut(&Cut, window, cx);
3325            this.select_previous(&SelectPrevious, window, cx);
3326            this.select_previous(&SelectPrevious, window, cx);
3327
3328            this.paste(&Paste, window, cx);
3329            this.update_visible_entries(None, false, false, window, cx);
3330        })
3331    });
3332    cx.run_until_parked();
3333    assert_eq!(
3334        visible_entries_as_strings(&panel, 0..10, cx),
3335        &[
3336            "v project_root",
3337            "    v dir_1",
3338            "        v nested_dir",
3339            "              file_1.py  <== marked",
3340            "              file_a.py  <== selected  <== marked",
3341        ]
3342    );
3343    cx.simulate_modifiers_change(modifiers_with_shift);
3344    cx.update(|window, cx| {
3345        panel.update(cx, |this, cx| {
3346            this.expand_selected_entry(&Default::default(), window, cx);
3347            this.select_next(&SelectNext, window, cx);
3348            this.select_next(&SelectNext, window, cx);
3349        })
3350    });
3351    submit_deletion(&panel, cx);
3352    assert_eq!(
3353        visible_entries_as_strings(&panel, 0..10, cx),
3354        &[
3355            "v project_root",
3356            "    v dir_1",
3357            "        v nested_dir  <== selected",
3358        ]
3359    );
3360}
3361
3362#[gpui::test]
3363async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
3364    init_test(cx);
3365
3366    let fs = FakeFs::new(cx.executor());
3367    fs.insert_tree(
3368        "/root",
3369        json!({
3370            "a": {
3371                "b": {
3372                    "c": {
3373                        "d": {}
3374                    }
3375                }
3376            },
3377            "target_destination": {}
3378        }),
3379    )
3380    .await;
3381
3382    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3383    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3384    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3385
3386    cx.update(|_, cx| {
3387        let settings = *ProjectPanelSettings::get_global(cx);
3388        ProjectPanelSettings::override_global(
3389            ProjectPanelSettings {
3390                auto_fold_dirs: true,
3391                ..settings
3392            },
3393            cx,
3394        );
3395    });
3396
3397    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3398    cx.run_until_parked();
3399
3400    // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
3401    select_path(&panel, "root/a/b/c/d", cx);
3402    panel.update_in(cx, |panel, window, cx| {
3403        let drag = DraggedSelection {
3404            active_selection: SelectedEntry {
3405                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3406                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3407            },
3408            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3409        };
3410        let target_entry = panel
3411            .project
3412            .read(cx)
3413            .visible_worktrees(cx)
3414            .next()
3415            .unwrap()
3416            .read(cx)
3417            .entry_for_path(rel_path("target_destination"))
3418            .unwrap();
3419        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3420    });
3421    cx.executor().run_until_parked();
3422
3423    assert_eq!(
3424        visible_entries_as_strings(&panel, 0..10, cx),
3425        &[
3426            "v root",
3427            "    > a/b/c",
3428            "    > target_destination/d  <== selected"
3429        ],
3430        "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
3431    );
3432
3433    // Reset
3434    select_path(&panel, "root/target_destination/d", cx);
3435    panel.update_in(cx, |panel, window, cx| {
3436        let drag = DraggedSelection {
3437            active_selection: SelectedEntry {
3438                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3439                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3440            },
3441            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3442        };
3443        let target_entry = panel
3444            .project
3445            .read(cx)
3446            .visible_worktrees(cx)
3447            .next()
3448            .unwrap()
3449            .read(cx)
3450            .entry_for_path(rel_path("a/b/c"))
3451            .unwrap();
3452        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3453    });
3454    cx.executor().run_until_parked();
3455
3456    // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
3457    select_path(&panel, "root/a/b", cx);
3458    panel.update_in(cx, |panel, window, cx| {
3459        let drag = DraggedSelection {
3460            active_selection: SelectedEntry {
3461                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3462                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3463            },
3464            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3465        };
3466        let target_entry = panel
3467            .project
3468            .read(cx)
3469            .visible_worktrees(cx)
3470            .next()
3471            .unwrap()
3472            .read(cx)
3473            .entry_for_path(rel_path("target_destination"))
3474            .unwrap();
3475        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3476    });
3477    cx.executor().run_until_parked();
3478
3479    assert_eq!(
3480        visible_entries_as_strings(&panel, 0..10, cx),
3481        &["v root", "    v a", "    > target_destination/b/c/d"],
3482        "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
3483    );
3484
3485    // Reset
3486    select_path(&panel, "root/target_destination/b", cx);
3487    panel.update_in(cx, |panel, window, cx| {
3488        let drag = DraggedSelection {
3489            active_selection: SelectedEntry {
3490                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3491                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3492            },
3493            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3494        };
3495        let target_entry = panel
3496            .project
3497            .read(cx)
3498            .visible_worktrees(cx)
3499            .next()
3500            .unwrap()
3501            .read(cx)
3502            .entry_for_path(rel_path("a"))
3503            .unwrap();
3504        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3505    });
3506    cx.executor().run_until_parked();
3507
3508    // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
3509    select_path(&panel, "root/a", cx);
3510    panel.update_in(cx, |panel, window, cx| {
3511        let drag = DraggedSelection {
3512            active_selection: SelectedEntry {
3513                worktree_id: panel.state.selection.as_ref().unwrap().worktree_id,
3514                entry_id: panel.resolve_entry(panel.state.selection.as_ref().unwrap().entry_id),
3515            },
3516            marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
3517        };
3518        let target_entry = panel
3519            .project
3520            .read(cx)
3521            .visible_worktrees(cx)
3522            .next()
3523            .unwrap()
3524            .read(cx)
3525            .entry_for_path(rel_path("target_destination"))
3526            .unwrap();
3527        panel.drag_onto(&drag, target_entry.id, false, window, cx);
3528    });
3529    cx.executor().run_until_parked();
3530
3531    assert_eq!(
3532        visible_entries_as_strings(&panel, 0..10, cx),
3533        &["v root", "    > target_destination/a/b/c/d"],
3534        "Moving first directory 'a' should move whole 'a/b/c/d' chain"
3535    );
3536}
3537
3538#[gpui::test]
3539async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3540    init_test_with_editor(cx);
3541    cx.update(|cx| {
3542        cx.update_global::<SettingsStore, _>(|store, cx| {
3543            store.update_user_settings(cx, |settings| {
3544                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3545                settings
3546                    .project_panel
3547                    .get_or_insert_default()
3548                    .auto_reveal_entries = Some(false);
3549            });
3550        })
3551    });
3552
3553    let fs = FakeFs::new(cx.background_executor.clone());
3554    fs.insert_tree(
3555        "/project_root",
3556        json!({
3557            ".git": {},
3558            ".gitignore": "**/gitignored_dir",
3559            "dir_1": {
3560                "file_1.py": "# File 1_1 contents",
3561                "file_2.py": "# File 1_2 contents",
3562                "file_3.py": "# File 1_3 contents",
3563                "gitignored_dir": {
3564                    "file_a.py": "# File contents",
3565                    "file_b.py": "# File contents",
3566                    "file_c.py": "# File contents",
3567                },
3568            },
3569            "dir_2": {
3570                "file_1.py": "# File 2_1 contents",
3571                "file_2.py": "# File 2_2 contents",
3572                "file_3.py": "# File 2_3 contents",
3573            }
3574        }),
3575    )
3576    .await;
3577
3578    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3579    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3580    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3581    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3582    cx.run_until_parked();
3583
3584    assert_eq!(
3585        visible_entries_as_strings(&panel, 0..20, cx),
3586        &[
3587            "v project_root",
3588            "    > .git",
3589            "    > dir_1",
3590            "    > dir_2",
3591            "      .gitignore",
3592        ]
3593    );
3594
3595    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3596        .expect("dir 1 file is not ignored and should have an entry");
3597    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3598        .expect("dir 2 file is not ignored and should have an entry");
3599    let gitignored_dir_file =
3600        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3601    assert_eq!(
3602        gitignored_dir_file, None,
3603        "File in the gitignored dir should not have an entry before its dir is toggled"
3604    );
3605
3606    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3607    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3608    cx.executor().run_until_parked();
3609    assert_eq!(
3610        visible_entries_as_strings(&panel, 0..20, cx),
3611        &[
3612            "v project_root",
3613            "    > .git",
3614            "    v dir_1",
3615            "        v gitignored_dir  <== selected",
3616            "              file_a.py",
3617            "              file_b.py",
3618            "              file_c.py",
3619            "          file_1.py",
3620            "          file_2.py",
3621            "          file_3.py",
3622            "    > dir_2",
3623            "      .gitignore",
3624        ],
3625        "Should show gitignored dir file list in the project panel"
3626    );
3627    let gitignored_dir_file =
3628        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3629            .expect("after gitignored dir got opened, a file entry should be present");
3630
3631    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3632    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3633    assert_eq!(
3634        visible_entries_as_strings(&panel, 0..20, cx),
3635        &[
3636            "v project_root",
3637            "    > .git",
3638            "    > dir_1  <== selected",
3639            "    > dir_2",
3640            "      .gitignore",
3641        ],
3642        "Should hide all dir contents again and prepare for the auto reveal test"
3643    );
3644
3645    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3646        panel.update(cx, |panel, cx| {
3647            panel.project.update(cx, |_, cx| {
3648                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3649            })
3650        });
3651        cx.run_until_parked();
3652        assert_eq!(
3653            visible_entries_as_strings(&panel, 0..20, cx),
3654            &[
3655                "v project_root",
3656                "    > .git",
3657                "    > dir_1  <== selected",
3658                "    > dir_2",
3659                "      .gitignore",
3660            ],
3661            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3662        );
3663    }
3664
3665    cx.update(|_, cx| {
3666        cx.update_global::<SettingsStore, _>(|store, cx| {
3667            store.update_user_settings(cx, |settings| {
3668                settings
3669                    .project_panel
3670                    .get_or_insert_default()
3671                    .auto_reveal_entries = Some(true)
3672            });
3673        })
3674    });
3675
3676    panel.update(cx, |panel, cx| {
3677        panel.project.update(cx, |_, cx| {
3678            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3679        })
3680    });
3681    cx.run_until_parked();
3682    assert_eq!(
3683        visible_entries_as_strings(&panel, 0..20, cx),
3684        &[
3685            "v project_root",
3686            "    > .git",
3687            "    v dir_1",
3688            "        > gitignored_dir",
3689            "          file_1.py  <== selected  <== marked",
3690            "          file_2.py",
3691            "          file_3.py",
3692            "    > dir_2",
3693            "      .gitignore",
3694        ],
3695        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3696    );
3697
3698    panel.update(cx, |panel, cx| {
3699        panel.project.update(cx, |_, cx| {
3700            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3701        })
3702    });
3703    cx.run_until_parked();
3704    assert_eq!(
3705        visible_entries_as_strings(&panel, 0..20, cx),
3706        &[
3707            "v project_root",
3708            "    > .git",
3709            "    v dir_1",
3710            "        > gitignored_dir",
3711            "          file_1.py",
3712            "          file_2.py",
3713            "          file_3.py",
3714            "    v dir_2",
3715            "          file_1.py  <== selected  <== marked",
3716            "          file_2.py",
3717            "          file_3.py",
3718            "      .gitignore",
3719        ],
3720        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3721    );
3722
3723    panel.update(cx, |panel, cx| {
3724        panel.project.update(cx, |_, cx| {
3725            cx.emit(project::Event::ActiveEntryChanged(Some(
3726                gitignored_dir_file,
3727            )))
3728        })
3729    });
3730    cx.run_until_parked();
3731    assert_eq!(
3732        visible_entries_as_strings(&panel, 0..20, cx),
3733        &[
3734            "v project_root",
3735            "    > .git",
3736            "    v dir_1",
3737            "        > gitignored_dir",
3738            "          file_1.py",
3739            "          file_2.py",
3740            "          file_3.py",
3741            "    v dir_2",
3742            "          file_1.py  <== selected  <== marked",
3743            "          file_2.py",
3744            "          file_3.py",
3745            "      .gitignore",
3746        ],
3747        "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3748    );
3749
3750    panel.update(cx, |panel, cx| {
3751        panel.project.update(cx, |_, cx| {
3752            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3753        })
3754    });
3755    cx.run_until_parked();
3756    assert_eq!(
3757        visible_entries_as_strings(&panel, 0..20, cx),
3758        &[
3759            "v project_root",
3760            "    > .git",
3761            "    v dir_1",
3762            "        v gitignored_dir",
3763            "              file_a.py  <== selected  <== marked",
3764            "              file_b.py",
3765            "              file_c.py",
3766            "          file_1.py",
3767            "          file_2.py",
3768            "          file_3.py",
3769            "    v dir_2",
3770            "          file_1.py",
3771            "          file_2.py",
3772            "          file_3.py",
3773            "      .gitignore",
3774        ],
3775        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3776    );
3777}
3778
3779#[gpui::test]
3780async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3781    init_test_with_editor(cx);
3782    cx.update(|cx| {
3783        cx.update_global::<SettingsStore, _>(|store, cx| {
3784            store.update_user_settings(cx, |settings| {
3785                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3786                settings.project.worktree.file_scan_inclusions =
3787                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3788                settings
3789                    .project_panel
3790                    .get_or_insert_default()
3791                    .auto_reveal_entries = Some(false)
3792            });
3793        })
3794    });
3795
3796    let fs = FakeFs::new(cx.background_executor.clone());
3797    fs.insert_tree(
3798        "/project_root",
3799        json!({
3800            ".git": {},
3801            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3802            "dir_1": {
3803                "file_1.py": "# File 1_1 contents",
3804                "file_2.py": "# File 1_2 contents",
3805                "file_3.py": "# File 1_3 contents",
3806                "gitignored_dir": {
3807                    "file_a.py": "# File contents",
3808                    "file_b.py": "# File contents",
3809                    "file_c.py": "# File contents",
3810                },
3811            },
3812            "dir_2": {
3813                "file_1.py": "# File 2_1 contents",
3814                "file_2.py": "# File 2_2 contents",
3815                "file_3.py": "# File 2_3 contents",
3816            },
3817            "always_included_but_ignored_dir": {
3818                "file_a.py": "# File contents",
3819                "file_b.py": "# File contents",
3820                "file_c.py": "# File contents",
3821            },
3822        }),
3823    )
3824    .await;
3825
3826    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3827    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3828    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3829    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3830    cx.run_until_parked();
3831
3832    assert_eq!(
3833        visible_entries_as_strings(&panel, 0..20, cx),
3834        &[
3835            "v project_root",
3836            "    > .git",
3837            "    > always_included_but_ignored_dir",
3838            "    > dir_1",
3839            "    > dir_2",
3840            "      .gitignore",
3841        ]
3842    );
3843
3844    let gitignored_dir_file =
3845        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3846    let always_included_but_ignored_dir_file = find_project_entry(
3847        &panel,
3848        "project_root/always_included_but_ignored_dir/file_a.py",
3849        cx,
3850    )
3851    .expect("file that is .gitignored but set to always be included should have an entry");
3852    assert_eq!(
3853        gitignored_dir_file, None,
3854        "File in the gitignored dir should not have an entry unless its directory is toggled"
3855    );
3856
3857    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3858    cx.run_until_parked();
3859    cx.update(|_, cx| {
3860        cx.update_global::<SettingsStore, _>(|store, cx| {
3861            store.update_user_settings(cx, |settings| {
3862                settings
3863                    .project_panel
3864                    .get_or_insert_default()
3865                    .auto_reveal_entries = Some(true)
3866            });
3867        })
3868    });
3869
3870    panel.update(cx, |panel, cx| {
3871        panel.project.update(cx, |_, cx| {
3872            cx.emit(project::Event::ActiveEntryChanged(Some(
3873                always_included_but_ignored_dir_file,
3874            )))
3875        })
3876    });
3877    cx.run_until_parked();
3878
3879    assert_eq!(
3880        visible_entries_as_strings(&panel, 0..20, cx),
3881        &[
3882            "v project_root",
3883            "    > .git",
3884            "    v always_included_but_ignored_dir",
3885            "          file_a.py  <== selected  <== marked",
3886            "          file_b.py",
3887            "          file_c.py",
3888            "    v dir_1",
3889            "        > gitignored_dir",
3890            "          file_1.py",
3891            "          file_2.py",
3892            "          file_3.py",
3893            "    > dir_2",
3894            "      .gitignore",
3895        ],
3896        "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3897    );
3898}
3899
3900#[gpui::test]
3901async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3902    init_test_with_editor(cx);
3903    cx.update(|cx| {
3904        cx.update_global::<SettingsStore, _>(|store, cx| {
3905            store.update_user_settings(cx, |settings| {
3906                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3907                settings
3908                    .project_panel
3909                    .get_or_insert_default()
3910                    .auto_reveal_entries = Some(false)
3911            });
3912        })
3913    });
3914
3915    let fs = FakeFs::new(cx.background_executor.clone());
3916    fs.insert_tree(
3917        "/project_root",
3918        json!({
3919            ".git": {},
3920            ".gitignore": "**/gitignored_dir",
3921            "dir_1": {
3922                "file_1.py": "# File 1_1 contents",
3923                "file_2.py": "# File 1_2 contents",
3924                "file_3.py": "# File 1_3 contents",
3925                "gitignored_dir": {
3926                    "file_a.py": "# File contents",
3927                    "file_b.py": "# File contents",
3928                    "file_c.py": "# File contents",
3929                },
3930            },
3931            "dir_2": {
3932                "file_1.py": "# File 2_1 contents",
3933                "file_2.py": "# File 2_2 contents",
3934                "file_3.py": "# File 2_3 contents",
3935            }
3936        }),
3937    )
3938    .await;
3939
3940    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3941    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3942    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3943    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3944    cx.run_until_parked();
3945
3946    assert_eq!(
3947        visible_entries_as_strings(&panel, 0..20, cx),
3948        &[
3949            "v project_root",
3950            "    > .git",
3951            "    > dir_1",
3952            "    > dir_2",
3953            "      .gitignore",
3954        ]
3955    );
3956
3957    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3958        .expect("dir 1 file is not ignored and should have an entry");
3959    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3960        .expect("dir 2 file is not ignored and should have an entry");
3961    let gitignored_dir_file =
3962        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3963    assert_eq!(
3964        gitignored_dir_file, None,
3965        "File in the gitignored dir should not have an entry before its dir is toggled"
3966    );
3967
3968    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3969    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3970    cx.run_until_parked();
3971    assert_eq!(
3972        visible_entries_as_strings(&panel, 0..20, cx),
3973        &[
3974            "v project_root",
3975            "    > .git",
3976            "    v dir_1",
3977            "        v gitignored_dir  <== selected",
3978            "              file_a.py",
3979            "              file_b.py",
3980            "              file_c.py",
3981            "          file_1.py",
3982            "          file_2.py",
3983            "          file_3.py",
3984            "    > dir_2",
3985            "      .gitignore",
3986        ],
3987        "Should show gitignored dir file list in the project panel"
3988    );
3989    let gitignored_dir_file =
3990        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3991            .expect("after gitignored dir got opened, a file entry should be present");
3992
3993    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3994    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3995    assert_eq!(
3996        visible_entries_as_strings(&panel, 0..20, cx),
3997        &[
3998            "v project_root",
3999            "    > .git",
4000            "    > dir_1  <== selected",
4001            "    > dir_2",
4002            "      .gitignore",
4003        ],
4004        "Should hide all dir contents again and prepare for the explicit reveal test"
4005    );
4006
4007    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4008        panel.update(cx, |panel, cx| {
4009            panel.project.update(cx, |_, cx| {
4010                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4011            })
4012        });
4013        cx.run_until_parked();
4014        assert_eq!(
4015            visible_entries_as_strings(&panel, 0..20, cx),
4016            &[
4017                "v project_root",
4018                "    > .git",
4019                "    > dir_1  <== selected",
4020                "    > dir_2",
4021                "      .gitignore",
4022            ],
4023            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4024        );
4025    }
4026
4027    panel.update(cx, |panel, cx| {
4028        panel.project.update(cx, |_, cx| {
4029            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4030        })
4031    });
4032    cx.run_until_parked();
4033    assert_eq!(
4034        visible_entries_as_strings(&panel, 0..20, cx),
4035        &[
4036            "v project_root",
4037            "    > .git",
4038            "    v dir_1",
4039            "        > gitignored_dir",
4040            "          file_1.py  <== selected  <== marked",
4041            "          file_2.py",
4042            "          file_3.py",
4043            "    > dir_2",
4044            "      .gitignore",
4045        ],
4046        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4047    );
4048
4049    panel.update(cx, |panel, cx| {
4050        panel.project.update(cx, |_, cx| {
4051            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4052        })
4053    });
4054    cx.run_until_parked();
4055    assert_eq!(
4056        visible_entries_as_strings(&panel, 0..20, cx),
4057        &[
4058            "v project_root",
4059            "    > .git",
4060            "    v dir_1",
4061            "        > gitignored_dir",
4062            "          file_1.py",
4063            "          file_2.py",
4064            "          file_3.py",
4065            "    v dir_2",
4066            "          file_1.py  <== selected  <== marked",
4067            "          file_2.py",
4068            "          file_3.py",
4069            "      .gitignore",
4070        ],
4071        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4072    );
4073
4074    panel.update(cx, |panel, cx| {
4075        panel.project.update(cx, |_, cx| {
4076            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4077        })
4078    });
4079    cx.run_until_parked();
4080    assert_eq!(
4081        visible_entries_as_strings(&panel, 0..20, cx),
4082        &[
4083            "v project_root",
4084            "    > .git",
4085            "    v dir_1",
4086            "        v gitignored_dir",
4087            "              file_a.py  <== selected  <== marked",
4088            "              file_b.py",
4089            "              file_c.py",
4090            "          file_1.py",
4091            "          file_2.py",
4092            "          file_3.py",
4093            "    v dir_2",
4094            "          file_1.py",
4095            "          file_2.py",
4096            "          file_3.py",
4097            "      .gitignore",
4098        ],
4099        "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4100    );
4101}
4102
4103#[gpui::test]
4104async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4105    init_test(cx);
4106    cx.update(|cx| {
4107        cx.update_global::<SettingsStore, _>(|store, cx| {
4108            store.update_user_settings(cx, |settings| {
4109                settings.project.worktree.file_scan_exclusions =
4110                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4111            });
4112        });
4113    });
4114
4115    cx.update(|cx| {
4116        register_project_item::<TestProjectItemView>(cx);
4117    });
4118
4119    let fs = FakeFs::new(cx.executor());
4120    fs.insert_tree(
4121        "/root1",
4122        json!({
4123            ".dockerignore": "",
4124            ".git": {
4125                "HEAD": "",
4126            },
4127        }),
4128    )
4129    .await;
4130
4131    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4132    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4133    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4134    let panel = workspace
4135        .update(cx, |workspace, window, cx| {
4136            let panel = ProjectPanel::new(workspace, window, cx);
4137            workspace.add_panel(panel.clone(), window, cx);
4138            panel
4139        })
4140        .unwrap();
4141    cx.run_until_parked();
4142
4143    select_path(&panel, "root1", cx);
4144    assert_eq!(
4145        visible_entries_as_strings(&panel, 0..10, cx),
4146        &["v root1  <== selected", "      .dockerignore",]
4147    );
4148    workspace
4149        .update(cx, |workspace, _, cx| {
4150            assert!(
4151                workspace.active_item(cx).is_none(),
4152                "Should have no active items in the beginning"
4153            );
4154        })
4155        .unwrap();
4156
4157    let excluded_file_path = ".git/COMMIT_EDITMSG";
4158    let excluded_dir_path = "excluded_dir";
4159
4160    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
4161    cx.run_until_parked();
4162    panel.update_in(cx, |panel, window, cx| {
4163        assert!(panel.filename_editor.read(cx).is_focused(window));
4164    });
4165    panel
4166        .update_in(cx, |panel, window, cx| {
4167            panel.filename_editor.update(cx, |editor, cx| {
4168                editor.set_text(excluded_file_path, window, cx)
4169            });
4170            panel.confirm_edit(window, cx).unwrap()
4171        })
4172        .await
4173        .unwrap();
4174
4175    assert_eq!(
4176        visible_entries_as_strings(&panel, 0..13, cx),
4177        &["v root1", "      .dockerignore"],
4178        "Excluded dir should not be shown after opening a file in it"
4179    );
4180    panel.update_in(cx, |panel, window, cx| {
4181        assert!(
4182            !panel.filename_editor.read(cx).is_focused(window),
4183            "Should have closed the file name editor"
4184        );
4185    });
4186    workspace
4187        .update(cx, |workspace, _, cx| {
4188            let active_entry_path = workspace
4189                .active_item(cx)
4190                .expect("should have opened and activated the excluded item")
4191                .act_as::<TestProjectItemView>(cx)
4192                .expect("should have opened the corresponding project item for the excluded item")
4193                .read(cx)
4194                .path
4195                .clone();
4196            assert_eq!(
4197                active_entry_path.path.as_ref(),
4198                rel_path(excluded_file_path),
4199                "Should open the excluded file"
4200            );
4201
4202            assert!(
4203                workspace.notification_ids().is_empty(),
4204                "Should have no notifications after opening an excluded file"
4205            );
4206        })
4207        .unwrap();
4208    assert!(
4209        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4210        "Should have created the excluded file"
4211    );
4212
4213    select_path(&panel, "root1", cx);
4214    panel.update_in(cx, |panel, window, cx| {
4215        panel.new_directory(&NewDirectory, window, cx)
4216    });
4217    cx.run_until_parked();
4218    panel.update_in(cx, |panel, window, cx| {
4219        assert!(panel.filename_editor.read(cx).is_focused(window));
4220    });
4221    panel
4222        .update_in(cx, |panel, window, cx| {
4223            panel.filename_editor.update(cx, |editor, cx| {
4224                editor.set_text(excluded_file_path, window, cx)
4225            });
4226            panel.confirm_edit(window, cx).unwrap()
4227        })
4228        .await
4229        .unwrap();
4230    cx.run_until_parked();
4231    assert_eq!(
4232        visible_entries_as_strings(&panel, 0..13, cx),
4233        &["v root1", "      .dockerignore"],
4234        "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4235    );
4236    panel.update_in(cx, |panel, window, cx| {
4237        assert!(
4238            !panel.filename_editor.read(cx).is_focused(window),
4239            "Should have closed the file name editor"
4240        );
4241    });
4242    workspace
4243        .update(cx, |workspace, _, cx| {
4244            let notifications = workspace.notification_ids();
4245            assert_eq!(
4246                notifications.len(),
4247                1,
4248                "Should receive one notification with the error message"
4249            );
4250            workspace.dismiss_notification(notifications.first().unwrap(), cx);
4251            assert!(workspace.notification_ids().is_empty());
4252        })
4253        .unwrap();
4254
4255    select_path(&panel, "root1", cx);
4256    panel.update_in(cx, |panel, window, cx| {
4257        panel.new_directory(&NewDirectory, window, cx)
4258    });
4259    cx.run_until_parked();
4260
4261    panel.update_in(cx, |panel, window, cx| {
4262        assert!(panel.filename_editor.read(cx).is_focused(window));
4263    });
4264
4265    panel
4266        .update_in(cx, |panel, window, cx| {
4267            panel.filename_editor.update(cx, |editor, cx| {
4268                editor.set_text(excluded_dir_path, window, cx)
4269            });
4270            panel.confirm_edit(window, cx).unwrap()
4271        })
4272        .await
4273        .unwrap();
4274
4275    cx.run_until_parked();
4276
4277    assert_eq!(
4278        visible_entries_as_strings(&panel, 0..13, cx),
4279        &["v root1", "      .dockerignore"],
4280        "Should not change the project panel after trying to create an excluded directory"
4281    );
4282    panel.update_in(cx, |panel, window, cx| {
4283        assert!(
4284            !panel.filename_editor.read(cx).is_focused(window),
4285            "Should have closed the file name editor"
4286        );
4287    });
4288    workspace
4289        .update(cx, |workspace, _, cx| {
4290            let notifications = workspace.notification_ids();
4291            assert_eq!(
4292                notifications.len(),
4293                1,
4294                "Should receive one notification explaining that no directory is actually shown"
4295            );
4296            workspace.dismiss_notification(notifications.first().unwrap(), cx);
4297            assert!(workspace.notification_ids().is_empty());
4298        })
4299        .unwrap();
4300    assert!(
4301        fs.is_dir(Path::new("/root1/excluded_dir")).await,
4302        "Should have created the excluded directory"
4303    );
4304}
4305
4306#[gpui::test]
4307async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
4308    init_test_with_editor(cx);
4309
4310    let fs = FakeFs::new(cx.executor());
4311    fs.insert_tree(
4312        "/src",
4313        json!({
4314            "test": {
4315                "first.rs": "// First Rust file",
4316                "second.rs": "// Second Rust file",
4317                "third.rs": "// Third Rust file",
4318            }
4319        }),
4320    )
4321    .await;
4322
4323    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4324    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4325    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4326    let panel = workspace
4327        .update(cx, |workspace, window, cx| {
4328            let panel = ProjectPanel::new(workspace, window, cx);
4329            workspace.add_panel(panel.clone(), window, cx);
4330            panel
4331        })
4332        .unwrap();
4333    cx.run_until_parked();
4334
4335    select_path(&panel, "src", cx);
4336    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
4337    cx.executor().run_until_parked();
4338    assert_eq!(
4339        visible_entries_as_strings(&panel, 0..10, cx),
4340        &[
4341            //
4342            "v src  <== selected",
4343            "    > test"
4344        ]
4345    );
4346    panel.update_in(cx, |panel, window, cx| {
4347        panel.new_directory(&NewDirectory, window, cx)
4348    });
4349    cx.executor().run_until_parked();
4350    panel.update_in(cx, |panel, window, cx| {
4351        assert!(panel.filename_editor.read(cx).is_focused(window));
4352    });
4353    assert_eq!(
4354        visible_entries_as_strings(&panel, 0..10, cx),
4355        &[
4356            //
4357            "v src",
4358            "    > [EDITOR: '']  <== selected",
4359            "    > test"
4360        ]
4361    );
4362
4363    panel.update_in(cx, |panel, window, cx| {
4364        panel.cancel(&menu::Cancel, window, cx);
4365        panel.update_visible_entries(None, false, false, window, cx);
4366    });
4367    cx.executor().run_until_parked();
4368    assert_eq!(
4369        visible_entries_as_strings(&panel, 0..10, cx),
4370        &[
4371            //
4372            "v src  <== selected",
4373            "    > test"
4374        ]
4375    );
4376}
4377
4378#[gpui::test]
4379async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
4380    init_test_with_editor(cx);
4381
4382    let fs = FakeFs::new(cx.executor());
4383    fs.insert_tree(
4384        "/root",
4385        json!({
4386            "dir1": {
4387                "subdir1": {},
4388                "file1.txt": "",
4389                "file2.txt": "",
4390            },
4391            "dir2": {
4392                "subdir2": {},
4393                "file3.txt": "",
4394                "file4.txt": "",
4395            },
4396            "file5.txt": "",
4397            "file6.txt": "",
4398        }),
4399    )
4400    .await;
4401
4402    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4403    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4404    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4405    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4406    cx.run_until_parked();
4407
4408    toggle_expand_dir(&panel, "root/dir1", cx);
4409    toggle_expand_dir(&panel, "root/dir2", cx);
4410
4411    // Test Case 1: Delete middle file in directory
4412    select_path(&panel, "root/dir1/file1.txt", cx);
4413    assert_eq!(
4414        visible_entries_as_strings(&panel, 0..15, cx),
4415        &[
4416            "v root",
4417            "    v dir1",
4418            "        > subdir1",
4419            "          file1.txt  <== selected",
4420            "          file2.txt",
4421            "    v dir2",
4422            "        > subdir2",
4423            "          file3.txt",
4424            "          file4.txt",
4425            "      file5.txt",
4426            "      file6.txt",
4427        ],
4428        "Initial state before deleting middle file"
4429    );
4430
4431    submit_deletion(&panel, cx);
4432    assert_eq!(
4433        visible_entries_as_strings(&panel, 0..15, cx),
4434        &[
4435            "v root",
4436            "    v dir1",
4437            "        > subdir1",
4438            "          file2.txt  <== selected",
4439            "    v dir2",
4440            "        > subdir2",
4441            "          file3.txt",
4442            "          file4.txt",
4443            "      file5.txt",
4444            "      file6.txt",
4445        ],
4446        "Should select next file after deleting middle file"
4447    );
4448
4449    // Test Case 2: Delete last file in directory
4450    submit_deletion(&panel, cx);
4451    assert_eq!(
4452        visible_entries_as_strings(&panel, 0..15, cx),
4453        &[
4454            "v root",
4455            "    v dir1",
4456            "        > subdir1  <== selected",
4457            "    v dir2",
4458            "        > subdir2",
4459            "          file3.txt",
4460            "          file4.txt",
4461            "      file5.txt",
4462            "      file6.txt",
4463        ],
4464        "Should select next directory when last file is deleted"
4465    );
4466
4467    // Test Case 3: Delete root level file
4468    select_path(&panel, "root/file6.txt", cx);
4469    assert_eq!(
4470        visible_entries_as_strings(&panel, 0..15, cx),
4471        &[
4472            "v root",
4473            "    v dir1",
4474            "        > subdir1",
4475            "    v dir2",
4476            "        > subdir2",
4477            "          file3.txt",
4478            "          file4.txt",
4479            "      file5.txt",
4480            "      file6.txt  <== selected",
4481        ],
4482        "Initial state before deleting root level file"
4483    );
4484
4485    submit_deletion(&panel, cx);
4486    assert_eq!(
4487        visible_entries_as_strings(&panel, 0..15, cx),
4488        &[
4489            "v root",
4490            "    v dir1",
4491            "        > subdir1",
4492            "    v dir2",
4493            "        > subdir2",
4494            "          file3.txt",
4495            "          file4.txt",
4496            "      file5.txt  <== selected",
4497        ],
4498        "Should select prev entry at root level"
4499    );
4500}
4501
4502#[gpui::test]
4503async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
4504    init_test_with_editor(cx);
4505
4506    let fs = FakeFs::new(cx.executor());
4507    fs.insert_tree(
4508        path!("/root"),
4509        json!({
4510            "aa": "// Testing 1",
4511            "bb": "// Testing 2",
4512            "cc": "// Testing 3",
4513            "dd": "// Testing 4",
4514            "ee": "// Testing 5",
4515            "ff": "// Testing 6",
4516            "gg": "// Testing 7",
4517            "hh": "// Testing 8",
4518            "ii": "// Testing 8",
4519            ".gitignore": "bb\ndd\nee\nff\nii\n'",
4520        }),
4521    )
4522    .await;
4523
4524    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4525    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4526    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4527
4528    // Test 1: Auto selection with one gitignored file next to the deleted file
4529    cx.update(|_, cx| {
4530        let settings = *ProjectPanelSettings::get_global(cx);
4531        ProjectPanelSettings::override_global(
4532            ProjectPanelSettings {
4533                hide_gitignore: true,
4534                ..settings
4535            },
4536            cx,
4537        );
4538    });
4539
4540    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4541    cx.run_until_parked();
4542
4543    select_path(&panel, "root/aa", cx);
4544    assert_eq!(
4545        visible_entries_as_strings(&panel, 0..10, cx),
4546        &[
4547            "v root",
4548            "      .gitignore",
4549            "      aa  <== selected",
4550            "      cc",
4551            "      gg",
4552            "      hh"
4553        ],
4554        "Initial state should hide files on .gitignore"
4555    );
4556
4557    submit_deletion(&panel, cx);
4558
4559    assert_eq!(
4560        visible_entries_as_strings(&panel, 0..10, cx),
4561        &[
4562            "v root",
4563            "      .gitignore",
4564            "      cc  <== selected",
4565            "      gg",
4566            "      hh"
4567        ],
4568        "Should select next entry not on .gitignore"
4569    );
4570
4571    // Test 2: Auto selection with many gitignored files next to the deleted file
4572    submit_deletion(&panel, cx);
4573    assert_eq!(
4574        visible_entries_as_strings(&panel, 0..10, cx),
4575        &[
4576            "v root",
4577            "      .gitignore",
4578            "      gg  <== selected",
4579            "      hh"
4580        ],
4581        "Should select next entry not on .gitignore"
4582    );
4583
4584    // Test 3: Auto selection of entry before deleted file
4585    select_path(&panel, "root/hh", cx);
4586    assert_eq!(
4587        visible_entries_as_strings(&panel, 0..10, cx),
4588        &[
4589            "v root",
4590            "      .gitignore",
4591            "      gg",
4592            "      hh  <== selected"
4593        ],
4594        "Should select next entry not on .gitignore"
4595    );
4596    submit_deletion(&panel, cx);
4597    assert_eq!(
4598        visible_entries_as_strings(&panel, 0..10, cx),
4599        &["v root", "      .gitignore", "      gg  <== selected"],
4600        "Should select next entry not on .gitignore"
4601    );
4602}
4603
4604#[gpui::test]
4605async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
4606    init_test_with_editor(cx);
4607
4608    let fs = FakeFs::new(cx.executor());
4609    fs.insert_tree(
4610        path!("/root"),
4611        json!({
4612            "dir1": {
4613                "file1": "// Testing",
4614                "file2": "// Testing",
4615                "file3": "// Testing"
4616            },
4617            "aa": "// Testing",
4618            ".gitignore": "file1\nfile3\n",
4619        }),
4620    )
4621    .await;
4622
4623    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4624    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4625    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4626
4627    cx.update(|_, cx| {
4628        let settings = *ProjectPanelSettings::get_global(cx);
4629        ProjectPanelSettings::override_global(
4630            ProjectPanelSettings {
4631                hide_gitignore: true,
4632                ..settings
4633            },
4634            cx,
4635        );
4636    });
4637
4638    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4639    cx.run_until_parked();
4640
4641    // Test 1: Visible items should exclude files on gitignore
4642    toggle_expand_dir(&panel, "root/dir1", cx);
4643    select_path(&panel, "root/dir1/file2", cx);
4644    assert_eq!(
4645        visible_entries_as_strings(&panel, 0..10, cx),
4646        &[
4647            "v root",
4648            "    v dir1",
4649            "          file2  <== selected",
4650            "      .gitignore",
4651            "      aa"
4652        ],
4653        "Initial state should hide files on .gitignore"
4654    );
4655    submit_deletion(&panel, cx);
4656
4657    // Test 2: Auto selection should go to the parent
4658    assert_eq!(
4659        visible_entries_as_strings(&panel, 0..10, cx),
4660        &[
4661            "v root",
4662            "    v dir1  <== selected",
4663            "      .gitignore",
4664            "      aa"
4665        ],
4666        "Initial state should hide files on .gitignore"
4667    );
4668}
4669
4670#[gpui::test]
4671async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
4672    init_test_with_editor(cx);
4673
4674    let fs = FakeFs::new(cx.executor());
4675    fs.insert_tree(
4676        "/root",
4677        json!({
4678            "dir1": {
4679                "subdir1": {
4680                    "a.txt": "",
4681                    "b.txt": ""
4682                },
4683                "file1.txt": "",
4684            },
4685            "dir2": {
4686                "subdir2": {
4687                    "c.txt": "",
4688                    "d.txt": ""
4689                },
4690                "file2.txt": "",
4691            },
4692            "file3.txt": "",
4693        }),
4694    )
4695    .await;
4696
4697    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4698    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4699    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4700    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4701    cx.run_until_parked();
4702
4703    toggle_expand_dir(&panel, "root/dir1", cx);
4704    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4705    toggle_expand_dir(&panel, "root/dir2", cx);
4706    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4707
4708    // Test Case 1: Select and delete nested directory with parent
4709    cx.simulate_modifiers_change(gpui::Modifiers {
4710        control: true,
4711        ..Default::default()
4712    });
4713    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4714    select_path_with_mark(&panel, "root/dir1", cx);
4715
4716    assert_eq!(
4717        visible_entries_as_strings(&panel, 0..15, cx),
4718        &[
4719            "v root",
4720            "    v dir1  <== selected  <== marked",
4721            "        v subdir1  <== marked",
4722            "              a.txt",
4723            "              b.txt",
4724            "          file1.txt",
4725            "    v dir2",
4726            "        v subdir2",
4727            "              c.txt",
4728            "              d.txt",
4729            "          file2.txt",
4730            "      file3.txt",
4731        ],
4732        "Initial state before deleting nested directory with parent"
4733    );
4734
4735    submit_deletion(&panel, cx);
4736    assert_eq!(
4737        visible_entries_as_strings(&panel, 0..15, cx),
4738        &[
4739            "v root",
4740            "    v dir2  <== selected",
4741            "        v subdir2",
4742            "              c.txt",
4743            "              d.txt",
4744            "          file2.txt",
4745            "      file3.txt",
4746        ],
4747        "Should select next directory after deleting directory with parent"
4748    );
4749
4750    // Test Case 2: Select mixed files and directories across levels
4751    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4752    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4753    select_path_with_mark(&panel, "root/file3.txt", cx);
4754
4755    assert_eq!(
4756        visible_entries_as_strings(&panel, 0..15, cx),
4757        &[
4758            "v root",
4759            "    v dir2",
4760            "        v subdir2",
4761            "              c.txt  <== marked",
4762            "              d.txt",
4763            "          file2.txt  <== marked",
4764            "      file3.txt  <== selected  <== marked",
4765        ],
4766        "Initial state before deleting"
4767    );
4768
4769    submit_deletion(&panel, cx);
4770    assert_eq!(
4771        visible_entries_as_strings(&panel, 0..15, cx),
4772        &[
4773            "v root",
4774            "    v dir2  <== selected",
4775            "        v subdir2",
4776            "              d.txt",
4777        ],
4778        "Should select sibling directory"
4779    );
4780}
4781
4782#[gpui::test]
4783async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4784    init_test_with_editor(cx);
4785
4786    let fs = FakeFs::new(cx.executor());
4787    fs.insert_tree(
4788        "/root",
4789        json!({
4790            "dir1": {
4791                "subdir1": {
4792                    "a.txt": "",
4793                    "b.txt": ""
4794                },
4795                "file1.txt": "",
4796            },
4797            "dir2": {
4798                "subdir2": {
4799                    "c.txt": "",
4800                    "d.txt": ""
4801                },
4802                "file2.txt": "",
4803            },
4804            "file3.txt": "",
4805            "file4.txt": "",
4806        }),
4807    )
4808    .await;
4809
4810    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4811    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4812    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4813    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4814    cx.run_until_parked();
4815
4816    toggle_expand_dir(&panel, "root/dir1", cx);
4817    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4818    toggle_expand_dir(&panel, "root/dir2", cx);
4819    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4820
4821    // Test Case 1: Select all root files and directories
4822    cx.simulate_modifiers_change(gpui::Modifiers {
4823        control: true,
4824        ..Default::default()
4825    });
4826    select_path_with_mark(&panel, "root/dir1", cx);
4827    select_path_with_mark(&panel, "root/dir2", cx);
4828    select_path_with_mark(&panel, "root/file3.txt", cx);
4829    select_path_with_mark(&panel, "root/file4.txt", cx);
4830    assert_eq!(
4831        visible_entries_as_strings(&panel, 0..20, cx),
4832        &[
4833            "v root",
4834            "    v dir1  <== marked",
4835            "        v subdir1",
4836            "              a.txt",
4837            "              b.txt",
4838            "          file1.txt",
4839            "    v dir2  <== marked",
4840            "        v subdir2",
4841            "              c.txt",
4842            "              d.txt",
4843            "          file2.txt",
4844            "      file3.txt  <== marked",
4845            "      file4.txt  <== selected  <== marked",
4846        ],
4847        "State before deleting all contents"
4848    );
4849
4850    submit_deletion(&panel, cx);
4851    assert_eq!(
4852        visible_entries_as_strings(&panel, 0..20, cx),
4853        &["v root  <== selected"],
4854        "Only empty root directory should remain after deleting all contents"
4855    );
4856}
4857
4858#[gpui::test]
4859async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4860    init_test_with_editor(cx);
4861
4862    let fs = FakeFs::new(cx.executor());
4863    fs.insert_tree(
4864        "/root",
4865        json!({
4866            "dir1": {
4867                "subdir1": {
4868                    "file_a.txt": "content a",
4869                    "file_b.txt": "content b",
4870                },
4871                "subdir2": {
4872                    "file_c.txt": "content c",
4873                },
4874                "file1.txt": "content 1",
4875            },
4876            "dir2": {
4877                "file2.txt": "content 2",
4878            },
4879        }),
4880    )
4881    .await;
4882
4883    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4884    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4885    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4886    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4887    cx.run_until_parked();
4888
4889    toggle_expand_dir(&panel, "root/dir1", cx);
4890    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4891    toggle_expand_dir(&panel, "root/dir2", cx);
4892    cx.simulate_modifiers_change(gpui::Modifiers {
4893        control: true,
4894        ..Default::default()
4895    });
4896
4897    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4898    select_path_with_mark(&panel, "root/dir1", cx);
4899    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4900    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4901
4902    assert_eq!(
4903        visible_entries_as_strings(&panel, 0..20, cx),
4904        &[
4905            "v root",
4906            "    v dir1  <== marked",
4907            "        v subdir1  <== marked",
4908            "              file_a.txt  <== selected  <== marked",
4909            "              file_b.txt",
4910            "        > subdir2",
4911            "          file1.txt",
4912            "    v dir2",
4913            "          file2.txt",
4914        ],
4915        "State with parent dir, subdir, and file selected"
4916    );
4917    submit_deletion(&panel, cx);
4918    assert_eq!(
4919        visible_entries_as_strings(&panel, 0..20, cx),
4920        &["v root", "    v dir2  <== selected", "          file2.txt",],
4921        "Only dir2 should remain after deletion"
4922    );
4923}
4924
4925#[gpui::test]
4926async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4927    init_test_with_editor(cx);
4928
4929    let fs = FakeFs::new(cx.executor());
4930    // First worktree
4931    fs.insert_tree(
4932        "/root1",
4933        json!({
4934            "dir1": {
4935                "file1.txt": "content 1",
4936                "file2.txt": "content 2",
4937            },
4938            "dir2": {
4939                "file3.txt": "content 3",
4940            },
4941        }),
4942    )
4943    .await;
4944
4945    // Second worktree
4946    fs.insert_tree(
4947        "/root2",
4948        json!({
4949            "dir3": {
4950                "file4.txt": "content 4",
4951                "file5.txt": "content 5",
4952            },
4953            "file6.txt": "content 6",
4954        }),
4955    )
4956    .await;
4957
4958    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4959    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4960    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4961    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4962    cx.run_until_parked();
4963
4964    // Expand all directories for testing
4965    toggle_expand_dir(&panel, "root1/dir1", cx);
4966    toggle_expand_dir(&panel, "root1/dir2", cx);
4967    toggle_expand_dir(&panel, "root2/dir3", cx);
4968
4969    // Test Case 1: Delete files across different worktrees
4970    cx.simulate_modifiers_change(gpui::Modifiers {
4971        control: true,
4972        ..Default::default()
4973    });
4974    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4975    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4976
4977    assert_eq!(
4978        visible_entries_as_strings(&panel, 0..20, cx),
4979        &[
4980            "v root1",
4981            "    v dir1",
4982            "          file1.txt  <== marked",
4983            "          file2.txt",
4984            "    v dir2",
4985            "          file3.txt",
4986            "v root2",
4987            "    v dir3",
4988            "          file4.txt  <== selected  <== marked",
4989            "          file5.txt",
4990            "      file6.txt",
4991        ],
4992        "Initial state with files selected from different worktrees"
4993    );
4994
4995    submit_deletion(&panel, cx);
4996    assert_eq!(
4997        visible_entries_as_strings(&panel, 0..20, cx),
4998        &[
4999            "v root1",
5000            "    v dir1",
5001            "          file2.txt",
5002            "    v dir2",
5003            "          file3.txt",
5004            "v root2",
5005            "    v dir3",
5006            "          file5.txt  <== selected",
5007            "      file6.txt",
5008        ],
5009        "Should select next file in the last worktree after deletion"
5010    );
5011
5012    // Test Case 2: Delete directories from different worktrees
5013    select_path_with_mark(&panel, "root1/dir1", cx);
5014    select_path_with_mark(&panel, "root2/dir3", cx);
5015
5016    assert_eq!(
5017        visible_entries_as_strings(&panel, 0..20, cx),
5018        &[
5019            "v root1",
5020            "    v dir1  <== marked",
5021            "          file2.txt",
5022            "    v dir2",
5023            "          file3.txt",
5024            "v root2",
5025            "    v dir3  <== selected  <== marked",
5026            "          file5.txt",
5027            "      file6.txt",
5028        ],
5029        "State with directories marked from different worktrees"
5030    );
5031
5032    submit_deletion(&panel, cx);
5033    assert_eq!(
5034        visible_entries_as_strings(&panel, 0..20, cx),
5035        &[
5036            "v root1",
5037            "    v dir2",
5038            "          file3.txt",
5039            "v root2",
5040            "      file6.txt  <== selected",
5041        ],
5042        "Should select remaining file in last worktree after directory deletion"
5043    );
5044
5045    // Test Case 4: Delete all remaining files except roots
5046    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
5047    select_path_with_mark(&panel, "root2/file6.txt", cx);
5048
5049    assert_eq!(
5050        visible_entries_as_strings(&panel, 0..20, cx),
5051        &[
5052            "v root1",
5053            "    v dir2",
5054            "          file3.txt  <== marked",
5055            "v root2",
5056            "      file6.txt  <== selected  <== marked",
5057        ],
5058        "State with all remaining files marked"
5059    );
5060
5061    submit_deletion(&panel, cx);
5062    assert_eq!(
5063        visible_entries_as_strings(&panel, 0..20, cx),
5064        &["v root1", "    v dir2", "v root2  <== selected"],
5065        "Second parent root should be selected after deleting"
5066    );
5067}
5068
5069#[gpui::test]
5070async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
5071    init_test_with_editor(cx);
5072
5073    let fs = FakeFs::new(cx.executor());
5074    fs.insert_tree(
5075        "/root",
5076        json!({
5077            "dir1": {
5078                "file1.txt": "",
5079                "file2.txt": "",
5080                "file3.txt": "",
5081            },
5082            "dir2": {
5083                "file4.txt": "",
5084                "file5.txt": "",
5085            },
5086        }),
5087    )
5088    .await;
5089
5090    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5091    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5092    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5093    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5094    cx.run_until_parked();
5095
5096    toggle_expand_dir(&panel, "root/dir1", cx);
5097    toggle_expand_dir(&panel, "root/dir2", cx);
5098
5099    cx.simulate_modifiers_change(gpui::Modifiers {
5100        control: true,
5101        ..Default::default()
5102    });
5103
5104    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
5105    select_path(&panel, "root/dir1/file1.txt", cx);
5106
5107    assert_eq!(
5108        visible_entries_as_strings(&panel, 0..15, cx),
5109        &[
5110            "v root",
5111            "    v dir1",
5112            "          file1.txt  <== selected",
5113            "          file2.txt  <== marked",
5114            "          file3.txt",
5115            "    v dir2",
5116            "          file4.txt",
5117            "          file5.txt",
5118        ],
5119        "Initial state with one marked entry and different selection"
5120    );
5121
5122    // Delete should operate on the selected entry (file1.txt)
5123    submit_deletion(&panel, cx);
5124    assert_eq!(
5125        visible_entries_as_strings(&panel, 0..15, cx),
5126        &[
5127            "v root",
5128            "    v dir1",
5129            "          file2.txt  <== selected  <== marked",
5130            "          file3.txt",
5131            "    v dir2",
5132            "          file4.txt",
5133            "          file5.txt",
5134        ],
5135        "Should delete selected file, not marked file"
5136    );
5137
5138    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
5139    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
5140    select_path(&panel, "root/dir2/file5.txt", cx);
5141
5142    assert_eq!(
5143        visible_entries_as_strings(&panel, 0..15, cx),
5144        &[
5145            "v root",
5146            "    v dir1",
5147            "          file2.txt  <== marked",
5148            "          file3.txt  <== marked",
5149            "    v dir2",
5150            "          file4.txt  <== marked",
5151            "          file5.txt  <== selected",
5152        ],
5153        "Initial state with multiple marked entries and different selection"
5154    );
5155
5156    // Delete should operate on all marked entries, ignoring the selection
5157    submit_deletion(&panel, cx);
5158    assert_eq!(
5159        visible_entries_as_strings(&panel, 0..15, cx),
5160        &[
5161            "v root",
5162            "    v dir1",
5163            "    v dir2",
5164            "          file5.txt  <== selected",
5165        ],
5166        "Should delete all marked files, leaving only the selected file"
5167    );
5168}
5169
5170#[gpui::test]
5171async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
5172    init_test_with_editor(cx);
5173
5174    let fs = FakeFs::new(cx.executor());
5175    fs.insert_tree(
5176        "/root_b",
5177        json!({
5178            "dir1": {
5179                "file1.txt": "content 1",
5180                "file2.txt": "content 2",
5181            },
5182        }),
5183    )
5184    .await;
5185
5186    fs.insert_tree(
5187        "/root_c",
5188        json!({
5189            "dir2": {},
5190        }),
5191    )
5192    .await;
5193
5194    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
5195    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5196    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5197    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5198    cx.run_until_parked();
5199
5200    toggle_expand_dir(&panel, "root_b/dir1", cx);
5201    toggle_expand_dir(&panel, "root_c/dir2", cx);
5202
5203    cx.simulate_modifiers_change(gpui::Modifiers {
5204        control: true,
5205        ..Default::default()
5206    });
5207    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
5208    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
5209
5210    assert_eq!(
5211        visible_entries_as_strings(&panel, 0..20, cx),
5212        &[
5213            "v root_b",
5214            "    v dir1",
5215            "          file1.txt  <== marked",
5216            "          file2.txt  <== selected  <== marked",
5217            "v root_c",
5218            "    v dir2",
5219        ],
5220        "Initial state with files marked in root_b"
5221    );
5222
5223    submit_deletion(&panel, cx);
5224    assert_eq!(
5225        visible_entries_as_strings(&panel, 0..20, cx),
5226        &[
5227            "v root_b",
5228            "    v dir1  <== selected",
5229            "v root_c",
5230            "    v dir2",
5231        ],
5232        "After deletion in root_b as it's last deletion, selection should be in root_b"
5233    );
5234
5235    select_path_with_mark(&panel, "root_c/dir2", cx);
5236
5237    submit_deletion(&panel, cx);
5238    assert_eq!(
5239        visible_entries_as_strings(&panel, 0..20, cx),
5240        &["v root_b", "    v dir1", "v root_c  <== selected",],
5241        "After deleting from root_c, it should remain in root_c"
5242    );
5243}
5244
5245fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
5246    let path = rel_path(path);
5247    panel.update_in(cx, |panel, window, cx| {
5248        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5249            let worktree = worktree.read(cx);
5250            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5251                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5252                panel.toggle_expanded(entry_id, window, cx);
5253                return;
5254            }
5255        }
5256        panic!("no worktree for path {:?}", path);
5257    });
5258    cx.run_until_parked();
5259}
5260
5261#[gpui::test]
5262async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
5263    init_test_with_editor(cx);
5264
5265    let fs = FakeFs::new(cx.executor());
5266    fs.insert_tree(
5267        path!("/root"),
5268        json!({
5269            ".gitignore": "**/ignored_dir\n**/ignored_nested",
5270            "dir1": {
5271                "empty1": {
5272                    "empty2": {
5273                        "empty3": {
5274                            "file.txt": ""
5275                        }
5276                    }
5277                },
5278                "subdir1": {
5279                    "file1.txt": "",
5280                    "file2.txt": "",
5281                    "ignored_nested": {
5282                        "ignored_file.txt": ""
5283                    }
5284                },
5285                "ignored_dir": {
5286                    "subdir": {
5287                        "deep_file.txt": ""
5288                    }
5289                }
5290            }
5291        }),
5292    )
5293    .await;
5294
5295    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5296    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5297    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5298
5299    // Test 1: When auto-fold is enabled
5300    cx.update(|_, cx| {
5301        let settings = *ProjectPanelSettings::get_global(cx);
5302        ProjectPanelSettings::override_global(
5303            ProjectPanelSettings {
5304                auto_fold_dirs: true,
5305                ..settings
5306            },
5307            cx,
5308        );
5309    });
5310
5311    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5312    cx.run_until_parked();
5313
5314    assert_eq!(
5315        visible_entries_as_strings(&panel, 0..20, cx),
5316        &["v root", "    > dir1", "      .gitignore",],
5317        "Initial state should show collapsed root structure"
5318    );
5319
5320    toggle_expand_dir(&panel, "root/dir1", cx);
5321    assert_eq!(
5322        visible_entries_as_strings(&panel, 0..20, cx),
5323        &[
5324            "v root",
5325            "    v dir1  <== selected",
5326            "        > empty1/empty2/empty3",
5327            "        > ignored_dir",
5328            "        > subdir1",
5329            "      .gitignore",
5330        ],
5331        "Should show first level with auto-folded dirs and ignored dir visible"
5332    );
5333
5334    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5335    panel.update_in(cx, |panel, window, cx| {
5336        let project = panel.project.read(cx);
5337        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5338        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5339        panel.update_visible_entries(None, false, false, window, cx);
5340    });
5341    cx.run_until_parked();
5342
5343    assert_eq!(
5344        visible_entries_as_strings(&panel, 0..20, cx),
5345        &[
5346            "v root",
5347            "    v dir1  <== selected",
5348            "        v empty1",
5349            "            v empty2",
5350            "                v empty3",
5351            "                      file.txt",
5352            "        > ignored_dir",
5353            "        v subdir1",
5354            "            > ignored_nested",
5355            "              file1.txt",
5356            "              file2.txt",
5357            "      .gitignore",
5358        ],
5359        "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
5360    );
5361
5362    // Test 2: When auto-fold is disabled
5363    cx.update(|_, cx| {
5364        let settings = *ProjectPanelSettings::get_global(cx);
5365        ProjectPanelSettings::override_global(
5366            ProjectPanelSettings {
5367                auto_fold_dirs: false,
5368                ..settings
5369            },
5370            cx,
5371        );
5372    });
5373
5374    panel.update_in(cx, |panel, window, cx| {
5375        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
5376    });
5377
5378    toggle_expand_dir(&panel, "root/dir1", cx);
5379    assert_eq!(
5380        visible_entries_as_strings(&panel, 0..20, cx),
5381        &[
5382            "v root",
5383            "    v dir1  <== selected",
5384            "        > empty1",
5385            "        > ignored_dir",
5386            "        > subdir1",
5387            "      .gitignore",
5388        ],
5389        "With auto-fold disabled: should show all directories separately"
5390    );
5391
5392    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5393    panel.update_in(cx, |panel, window, cx| {
5394        let project = panel.project.read(cx);
5395        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5396        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5397        panel.update_visible_entries(None, false, false, window, cx);
5398    });
5399    cx.run_until_parked();
5400
5401    assert_eq!(
5402        visible_entries_as_strings(&panel, 0..20, cx),
5403        &[
5404            "v root",
5405            "    v dir1  <== selected",
5406            "        v empty1",
5407            "            v empty2",
5408            "                v empty3",
5409            "                      file.txt",
5410            "        > ignored_dir",
5411            "        v subdir1",
5412            "            > ignored_nested",
5413            "              file1.txt",
5414            "              file2.txt",
5415            "      .gitignore",
5416        ],
5417        "After expand_all without auto-fold: should expand all dirs normally, \
5418         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
5419    );
5420
5421    // Test 3: When explicitly called on ignored directory
5422    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
5423    panel.update_in(cx, |panel, window, cx| {
5424        let project = panel.project.read(cx);
5425        let worktree = project.worktrees(cx).next().unwrap().read(cx);
5426        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
5427        panel.update_visible_entries(None, false, false, window, cx);
5428    });
5429    cx.run_until_parked();
5430
5431    assert_eq!(
5432        visible_entries_as_strings(&panel, 0..20, cx),
5433        &[
5434            "v root",
5435            "    v dir1  <== selected",
5436            "        v empty1",
5437            "            v empty2",
5438            "                v empty3",
5439            "                      file.txt",
5440            "        v ignored_dir",
5441            "            v subdir",
5442            "                  deep_file.txt",
5443            "        v subdir1",
5444            "            > ignored_nested",
5445            "              file1.txt",
5446            "              file2.txt",
5447            "      .gitignore",
5448        ],
5449        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
5450    );
5451}
5452
5453#[gpui::test]
5454async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
5455    init_test(cx);
5456
5457    let fs = FakeFs::new(cx.executor());
5458    fs.insert_tree(
5459        path!("/root"),
5460        json!({
5461            "dir1": {
5462                "subdir1": {
5463                    "nested1": {
5464                        "file1.txt": "",
5465                        "file2.txt": ""
5466                    },
5467                },
5468                "subdir2": {
5469                    "file4.txt": ""
5470                }
5471            },
5472            "dir2": {
5473                "single_file": {
5474                    "file5.txt": ""
5475                }
5476            }
5477        }),
5478    )
5479    .await;
5480
5481    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5482    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5483    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5484
5485    // Test 1: Basic collapsing
5486    {
5487        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5488        cx.run_until_parked();
5489
5490        toggle_expand_dir(&panel, "root/dir1", cx);
5491        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5492        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5493        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
5494
5495        assert_eq!(
5496            visible_entries_as_strings(&panel, 0..20, cx),
5497            &[
5498                "v root",
5499                "    v dir1",
5500                "        v subdir1",
5501                "            v nested1",
5502                "                  file1.txt",
5503                "                  file2.txt",
5504                "        v subdir2  <== selected",
5505                "              file4.txt",
5506                "    > dir2",
5507            ],
5508            "Initial state with everything expanded"
5509        );
5510
5511        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5512        panel.update_in(cx, |panel, window, cx| {
5513            let project = panel.project.read(cx);
5514            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5515            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5516            panel.update_visible_entries(None, false, false, window, cx);
5517        });
5518        cx.run_until_parked();
5519
5520        assert_eq!(
5521            visible_entries_as_strings(&panel, 0..20, cx),
5522            &["v root", "    > dir1", "    > dir2",],
5523            "All subdirs under dir1 should be collapsed"
5524        );
5525    }
5526
5527    // Test 2: With auto-fold enabled
5528    {
5529        cx.update(|_, cx| {
5530            let settings = *ProjectPanelSettings::get_global(cx);
5531            ProjectPanelSettings::override_global(
5532                ProjectPanelSettings {
5533                    auto_fold_dirs: true,
5534                    ..settings
5535                },
5536                cx,
5537            );
5538        });
5539
5540        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5541        cx.run_until_parked();
5542
5543        toggle_expand_dir(&panel, "root/dir1", cx);
5544        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5545        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5546
5547        assert_eq!(
5548            visible_entries_as_strings(&panel, 0..20, cx),
5549            &[
5550                "v root",
5551                "    v dir1",
5552                "        v subdir1/nested1  <== selected",
5553                "              file1.txt",
5554                "              file2.txt",
5555                "        > subdir2",
5556                "    > dir2/single_file",
5557            ],
5558            "Initial state with some dirs expanded"
5559        );
5560
5561        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5562        panel.update(cx, |panel, cx| {
5563            let project = panel.project.read(cx);
5564            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5565            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5566        });
5567
5568        toggle_expand_dir(&panel, "root/dir1", cx);
5569
5570        assert_eq!(
5571            visible_entries_as_strings(&panel, 0..20, cx),
5572            &[
5573                "v root",
5574                "    v dir1  <== selected",
5575                "        > subdir1/nested1",
5576                "        > subdir2",
5577                "    > dir2/single_file",
5578            ],
5579            "Subdirs should be collapsed and folded with auto-fold enabled"
5580        );
5581    }
5582
5583    // Test 3: With auto-fold disabled
5584    {
5585        cx.update(|_, cx| {
5586            let settings = *ProjectPanelSettings::get_global(cx);
5587            ProjectPanelSettings::override_global(
5588                ProjectPanelSettings {
5589                    auto_fold_dirs: false,
5590                    ..settings
5591                },
5592                cx,
5593            );
5594        });
5595
5596        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5597        cx.run_until_parked();
5598
5599        toggle_expand_dir(&panel, "root/dir1", cx);
5600        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5601        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5602
5603        assert_eq!(
5604            visible_entries_as_strings(&panel, 0..20, cx),
5605            &[
5606                "v root",
5607                "    v dir1",
5608                "        v subdir1",
5609                "            v nested1  <== selected",
5610                "                  file1.txt",
5611                "                  file2.txt",
5612                "        > subdir2",
5613                "    > dir2",
5614            ],
5615            "Initial state with some dirs expanded and auto-fold disabled"
5616        );
5617
5618        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5619        panel.update(cx, |panel, cx| {
5620            let project = panel.project.read(cx);
5621            let worktree = project.worktrees(cx).next().unwrap().read(cx);
5622            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5623        });
5624
5625        toggle_expand_dir(&panel, "root/dir1", cx);
5626
5627        assert_eq!(
5628            visible_entries_as_strings(&panel, 0..20, cx),
5629            &[
5630                "v root",
5631                "    v dir1  <== selected",
5632                "        > subdir1",
5633                "        > subdir2",
5634                "    > dir2",
5635            ],
5636            "Subdirs should be collapsed but not folded with auto-fold disabled"
5637        );
5638    }
5639}
5640
5641#[gpui::test]
5642async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
5643    init_test(cx);
5644
5645    let fs = FakeFs::new(cx.executor());
5646    fs.insert_tree(
5647        path!("/root"),
5648        json!({
5649            "dir1": {
5650                "file1.txt": "",
5651            },
5652        }),
5653    )
5654    .await;
5655
5656    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5657    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5658    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5659
5660    let panel = workspace
5661        .update(cx, |workspace, window, cx| {
5662            let panel = ProjectPanel::new(workspace, window, cx);
5663            workspace.add_panel(panel.clone(), window, cx);
5664            panel
5665        })
5666        .unwrap();
5667    cx.run_until_parked();
5668
5669    #[rustfmt::skip]
5670    assert_eq!(
5671        visible_entries_as_strings(&panel, 0..20, cx),
5672        &[
5673            "v root",
5674            "    > dir1",
5675        ],
5676        "Initial state with nothing selected"
5677    );
5678
5679    panel.update_in(cx, |panel, window, cx| {
5680        panel.new_file(&NewFile, window, cx);
5681    });
5682    cx.run_until_parked();
5683    panel.update_in(cx, |panel, window, cx| {
5684        assert!(panel.filename_editor.read(cx).is_focused(window));
5685    });
5686    panel
5687        .update_in(cx, |panel, window, cx| {
5688            panel.filename_editor.update(cx, |editor, cx| {
5689                editor.set_text("hello_from_no_selections", window, cx)
5690            });
5691            panel.confirm_edit(window, cx).unwrap()
5692        })
5693        .await
5694        .unwrap();
5695    cx.run_until_parked();
5696    #[rustfmt::skip]
5697    assert_eq!(
5698        visible_entries_as_strings(&panel, 0..20, cx),
5699        &[
5700            "v root",
5701            "    > dir1",
5702            "      hello_from_no_selections  <== selected  <== marked",
5703        ],
5704        "A new file is created under the root directory"
5705    );
5706}
5707
5708#[gpui::test]
5709async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
5710    init_test(cx);
5711
5712    let fs = FakeFs::new(cx.executor());
5713    fs.insert_tree(
5714        path!("/root"),
5715        json!({
5716            "existing_dir": {
5717                "existing_file.txt": "",
5718            },
5719            "existing_file.txt": "",
5720        }),
5721    )
5722    .await;
5723
5724    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5725    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5726    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5727
5728    cx.update(|_, cx| {
5729        let settings = *ProjectPanelSettings::get_global(cx);
5730        ProjectPanelSettings::override_global(
5731            ProjectPanelSettings {
5732                hide_root: true,
5733                ..settings
5734            },
5735            cx,
5736        );
5737    });
5738
5739    let panel = workspace
5740        .update(cx, |workspace, window, cx| {
5741            let panel = ProjectPanel::new(workspace, window, cx);
5742            workspace.add_panel(panel.clone(), window, cx);
5743            panel
5744        })
5745        .unwrap();
5746    cx.run_until_parked();
5747
5748    #[rustfmt::skip]
5749    assert_eq!(
5750        visible_entries_as_strings(&panel, 0..20, cx),
5751        &[
5752            "> existing_dir",
5753            "  existing_file.txt",
5754        ],
5755        "Initial state with hide_root=true, root should be hidden and nothing selected"
5756    );
5757
5758    panel.update(cx, |panel, _| {
5759        assert!(
5760            panel.state.selection.is_none(),
5761            "Should have no selection initially"
5762        );
5763    });
5764
5765    // Test 1: Create new file when no entry is selected
5766    panel.update_in(cx, |panel, window, cx| {
5767        panel.new_file(&NewFile, window, cx);
5768    });
5769    cx.run_until_parked();
5770    panel.update_in(cx, |panel, window, cx| {
5771        assert!(panel.filename_editor.read(cx).is_focused(window));
5772    });
5773    cx.run_until_parked();
5774    #[rustfmt::skip]
5775    assert_eq!(
5776        visible_entries_as_strings(&panel, 0..20, cx),
5777        &[
5778            "> existing_dir",
5779            "  [EDITOR: '']  <== selected",
5780            "  existing_file.txt",
5781        ],
5782        "Editor should appear at root level when hide_root=true and no selection"
5783    );
5784
5785    let confirm = panel.update_in(cx, |panel, window, cx| {
5786        panel.filename_editor.update(cx, |editor, cx| {
5787            editor.set_text("new_file_at_root.txt", window, cx)
5788        });
5789        panel.confirm_edit(window, cx).unwrap()
5790    });
5791    confirm.await.unwrap();
5792    cx.run_until_parked();
5793
5794    #[rustfmt::skip]
5795    assert_eq!(
5796        visible_entries_as_strings(&panel, 0..20, cx),
5797        &[
5798            "> existing_dir",
5799            "  existing_file.txt",
5800            "  new_file_at_root.txt  <== selected  <== marked",
5801        ],
5802        "New file should be created at root level and visible without root prefix"
5803    );
5804
5805    assert!(
5806        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
5807        "File should be created in the actual root directory"
5808    );
5809
5810    // Test 2: Create new directory when no entry is selected
5811    panel.update(cx, |panel, _| {
5812        panel.state.selection = None;
5813    });
5814
5815    panel.update_in(cx, |panel, window, cx| {
5816        panel.new_directory(&NewDirectory, window, cx);
5817    });
5818    cx.run_until_parked();
5819
5820    panel.update_in(cx, |panel, window, cx| {
5821        assert!(panel.filename_editor.read(cx).is_focused(window));
5822    });
5823
5824    #[rustfmt::skip]
5825    assert_eq!(
5826        visible_entries_as_strings(&panel, 0..20, cx),
5827        &[
5828            "> [EDITOR: '']  <== selected",
5829            "> existing_dir",
5830            "  existing_file.txt",
5831            "  new_file_at_root.txt",
5832        ],
5833        "Directory editor should appear at root level when hide_root=true and no selection"
5834    );
5835
5836    let confirm = panel.update_in(cx, |panel, window, cx| {
5837        panel.filename_editor.update(cx, |editor, cx| {
5838            editor.set_text("new_dir_at_root", window, cx)
5839        });
5840        panel.confirm_edit(window, cx).unwrap()
5841    });
5842    confirm.await.unwrap();
5843    cx.run_until_parked();
5844
5845    #[rustfmt::skip]
5846    assert_eq!(
5847        visible_entries_as_strings(&panel, 0..20, cx),
5848        &[
5849            "> existing_dir",
5850            "v new_dir_at_root  <== selected",
5851            "  existing_file.txt",
5852            "  new_file_at_root.txt",
5853        ],
5854        "New directory should be created at root level and visible without root prefix"
5855    );
5856
5857    assert!(
5858        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
5859        "Directory should be created in the actual root directory"
5860    );
5861}
5862
5863#[gpui::test]
5864async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
5865    init_test(cx);
5866
5867    let fs = FakeFs::new(cx.executor());
5868    fs.insert_tree(
5869        "/root",
5870        json!({
5871            "dir1": {
5872                "file1.txt": "",
5873                "dir2": {
5874                    "file2.txt": ""
5875                }
5876            },
5877            "file3.txt": ""
5878        }),
5879    )
5880    .await;
5881
5882    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5883    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5884    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5885    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5886    cx.run_until_parked();
5887
5888    panel.update(cx, |panel, cx| {
5889        let project = panel.project.read(cx);
5890        let worktree = project.visible_worktrees(cx).next().unwrap();
5891        let worktree = worktree.read(cx);
5892
5893        // Test 1: Target is a directory, should highlight the directory itself
5894        let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
5895        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
5896        assert_eq!(
5897            result,
5898            Some(dir_entry.id),
5899            "Should highlight directory itself"
5900        );
5901
5902        // Test 2: Target is nested file, should highlight immediate parent
5903        let nested_file = worktree
5904            .entry_for_path(rel_path("dir1/dir2/file2.txt"))
5905            .unwrap();
5906        let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
5907        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
5908        assert_eq!(
5909            result,
5910            Some(nested_parent.id),
5911            "Should highlight immediate parent"
5912        );
5913
5914        // Test 3: Target is root level file, should highlight root
5915        let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
5916        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
5917        assert_eq!(
5918            result,
5919            Some(worktree.root_entry().unwrap().id),
5920            "Root level file should return None"
5921        );
5922
5923        // Test 4: Target is root itself, should highlight root
5924        let root_entry = worktree.root_entry().unwrap();
5925        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
5926        assert_eq!(
5927            result,
5928            Some(root_entry.id),
5929            "Root level file should return None"
5930        );
5931    });
5932}
5933
5934#[gpui::test]
5935async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
5936    init_test(cx);
5937
5938    let fs = FakeFs::new(cx.executor());
5939    fs.insert_tree(
5940        "/root",
5941        json!({
5942            "parent_dir": {
5943                "child_file.txt": "",
5944                "sibling_file.txt": "",
5945                "child_dir": {
5946                    "nested_file.txt": ""
5947                }
5948            },
5949            "other_dir": {
5950                "other_file.txt": ""
5951            }
5952        }),
5953    )
5954    .await;
5955
5956    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5957    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5958    let cx = &mut VisualTestContext::from_window(*workspace, cx);
5959    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5960    cx.run_until_parked();
5961
5962    panel.update(cx, |panel, cx| {
5963        let project = panel.project.read(cx);
5964        let worktree = project.visible_worktrees(cx).next().unwrap();
5965        let worktree_id = worktree.read(cx).id();
5966        let worktree = worktree.read(cx);
5967
5968        let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
5969        let child_file = worktree
5970            .entry_for_path(rel_path("parent_dir/child_file.txt"))
5971            .unwrap();
5972        let sibling_file = worktree
5973            .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
5974            .unwrap();
5975        let child_dir = worktree
5976            .entry_for_path(rel_path("parent_dir/child_dir"))
5977            .unwrap();
5978        let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
5979        let other_file = worktree
5980            .entry_for_path(rel_path("other_dir/other_file.txt"))
5981            .unwrap();
5982
5983        // Test 1: Single item drag, don't highlight parent directory
5984        let dragged_selection = DraggedSelection {
5985            active_selection: SelectedEntry {
5986                worktree_id,
5987                entry_id: child_file.id,
5988            },
5989            marked_selections: Arc::new([SelectedEntry {
5990                worktree_id,
5991                entry_id: child_file.id,
5992            }]),
5993        };
5994        let result =
5995            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5996        assert_eq!(result, None, "Should not highlight parent of dragged item");
5997
5998        // Test 2: Single item drag, don't highlight sibling files
5999        let result = panel.highlight_entry_for_selection_drag(
6000            sibling_file,
6001            worktree,
6002            &dragged_selection,
6003            cx,
6004        );
6005        assert_eq!(result, None, "Should not highlight sibling files");
6006
6007        // Test 3: Single item drag, highlight unrelated directory
6008        let result =
6009            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
6010        assert_eq!(
6011            result,
6012            Some(other_dir.id),
6013            "Should highlight unrelated directory"
6014        );
6015
6016        // Test 4: Single item drag, highlight sibling directory
6017        let result =
6018            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
6019        assert_eq!(
6020            result,
6021            Some(child_dir.id),
6022            "Should highlight sibling directory"
6023        );
6024
6025        // Test 5: Multiple items drag, highlight parent directory
6026        let dragged_selection = DraggedSelection {
6027            active_selection: SelectedEntry {
6028                worktree_id,
6029                entry_id: child_file.id,
6030            },
6031            marked_selections: Arc::new([
6032                SelectedEntry {
6033                    worktree_id,
6034                    entry_id: child_file.id,
6035                },
6036                SelectedEntry {
6037                    worktree_id,
6038                    entry_id: sibling_file.id,
6039                },
6040            ]),
6041        };
6042        let result =
6043            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
6044        assert_eq!(
6045            result,
6046            Some(parent_dir.id),
6047            "Should highlight parent with multiple items"
6048        );
6049
6050        // Test 6: Target is file in different directory, highlight parent
6051        let result =
6052            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
6053        assert_eq!(
6054            result,
6055            Some(other_dir.id),
6056            "Should highlight parent of target file"
6057        );
6058
6059        // Test 7: Target is directory, always highlight
6060        let result =
6061            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
6062        assert_eq!(
6063            result,
6064            Some(child_dir.id),
6065            "Should always highlight directories"
6066        );
6067    });
6068}
6069
6070#[gpui::test]
6071async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
6072    init_test(cx);
6073
6074    let fs = FakeFs::new(cx.executor());
6075    fs.insert_tree(
6076        "/root1",
6077        json!({
6078            "src": {
6079                "main.rs": "",
6080                "lib.rs": ""
6081            }
6082        }),
6083    )
6084    .await;
6085    fs.insert_tree(
6086        "/root2",
6087        json!({
6088            "src": {
6089                "main.rs": "",
6090                "test.rs": ""
6091            }
6092        }),
6093    )
6094    .await;
6095
6096    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6097    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6098    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6099    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6100    cx.run_until_parked();
6101
6102    panel.update(cx, |panel, cx| {
6103        let project = panel.project.read(cx);
6104        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
6105
6106        let worktree_a = &worktrees[0];
6107        let main_rs_from_a = worktree_a
6108            .read(cx)
6109            .entry_for_path(rel_path("src/main.rs"))
6110            .unwrap();
6111
6112        let worktree_b = &worktrees[1];
6113        let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
6114        let main_rs_from_b = worktree_b
6115            .read(cx)
6116            .entry_for_path(rel_path("src/main.rs"))
6117            .unwrap();
6118
6119        // Test dragging file from worktree A onto parent of file with same relative path in worktree B
6120        let dragged_selection = DraggedSelection {
6121            active_selection: SelectedEntry {
6122                worktree_id: worktree_a.read(cx).id(),
6123                entry_id: main_rs_from_a.id,
6124            },
6125            marked_selections: Arc::new([SelectedEntry {
6126                worktree_id: worktree_a.read(cx).id(),
6127                entry_id: main_rs_from_a.id,
6128            }]),
6129        };
6130
6131        let result = panel.highlight_entry_for_selection_drag(
6132            src_dir_from_b,
6133            worktree_b.read(cx),
6134            &dragged_selection,
6135            cx,
6136        );
6137        assert_eq!(
6138            result,
6139            Some(src_dir_from_b.id),
6140            "Should highlight target directory from different worktree even with same relative path"
6141        );
6142
6143        // Test dragging file from worktree A onto file with same relative path in worktree B
6144        let result = panel.highlight_entry_for_selection_drag(
6145            main_rs_from_b,
6146            worktree_b.read(cx),
6147            &dragged_selection,
6148            cx,
6149        );
6150        assert_eq!(
6151            result,
6152            Some(src_dir_from_b.id),
6153            "Should highlight parent of target file from different worktree"
6154        );
6155    });
6156}
6157
6158#[gpui::test]
6159async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
6160    init_test(cx);
6161
6162    let fs = FakeFs::new(cx.executor());
6163    fs.insert_tree(
6164        "/root1",
6165        json!({
6166            "parent_dir": {
6167                "child_file.txt": "",
6168                "nested_dir": {
6169                    "nested_file.txt": ""
6170                }
6171            },
6172            "root_file.txt": ""
6173        }),
6174    )
6175    .await;
6176
6177    fs.insert_tree(
6178        "/root2",
6179        json!({
6180            "other_dir": {
6181                "other_file.txt": ""
6182            }
6183        }),
6184    )
6185    .await;
6186
6187    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6188    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6189    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6190    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6191    cx.run_until_parked();
6192
6193    panel.update(cx, |panel, cx| {
6194        let project = panel.project.read(cx);
6195        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
6196        let worktree1 = worktrees[0].read(cx);
6197        let worktree2 = worktrees[1].read(cx);
6198        let worktree1_id = worktree1.id();
6199        let _worktree2_id = worktree2.id();
6200
6201        let root1_entry = worktree1.root_entry().unwrap();
6202        let root2_entry = worktree2.root_entry().unwrap();
6203        let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
6204        let child_file = worktree1
6205            .entry_for_path(rel_path("parent_dir/child_file.txt"))
6206            .unwrap();
6207        let nested_file = worktree1
6208            .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
6209            .unwrap();
6210        let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
6211
6212        // Test 1: Multiple entries - should always highlight background
6213        let multiple_dragged_selection = DraggedSelection {
6214            active_selection: SelectedEntry {
6215                worktree_id: worktree1_id,
6216                entry_id: child_file.id,
6217            },
6218            marked_selections: Arc::new([
6219                SelectedEntry {
6220                    worktree_id: worktree1_id,
6221                    entry_id: child_file.id,
6222                },
6223                SelectedEntry {
6224                    worktree_id: worktree1_id,
6225                    entry_id: nested_file.id,
6226                },
6227            ]),
6228        };
6229
6230        let result = panel.should_highlight_background_for_selection_drag(
6231            &multiple_dragged_selection,
6232            root1_entry.id,
6233            cx,
6234        );
6235        assert!(result, "Should highlight background for multiple entries");
6236
6237        // Test 2: Single entry with non-empty parent path - should highlight background
6238        let nested_dragged_selection = DraggedSelection {
6239            active_selection: SelectedEntry {
6240                worktree_id: worktree1_id,
6241                entry_id: nested_file.id,
6242            },
6243            marked_selections: Arc::new([SelectedEntry {
6244                worktree_id: worktree1_id,
6245                entry_id: nested_file.id,
6246            }]),
6247        };
6248
6249        let result = panel.should_highlight_background_for_selection_drag(
6250            &nested_dragged_selection,
6251            root1_entry.id,
6252            cx,
6253        );
6254        assert!(result, "Should highlight background for nested file");
6255
6256        // Test 3: Single entry at root level, same worktree - should NOT highlight background
6257        let root_file_dragged_selection = DraggedSelection {
6258            active_selection: SelectedEntry {
6259                worktree_id: worktree1_id,
6260                entry_id: root_file.id,
6261            },
6262            marked_selections: Arc::new([SelectedEntry {
6263                worktree_id: worktree1_id,
6264                entry_id: root_file.id,
6265            }]),
6266        };
6267
6268        let result = panel.should_highlight_background_for_selection_drag(
6269            &root_file_dragged_selection,
6270            root1_entry.id,
6271            cx,
6272        );
6273        assert!(
6274            !result,
6275            "Should NOT highlight background for root file in same worktree"
6276        );
6277
6278        // Test 4: Single entry at root level, different worktree - should highlight background
6279        let result = panel.should_highlight_background_for_selection_drag(
6280            &root_file_dragged_selection,
6281            root2_entry.id,
6282            cx,
6283        );
6284        assert!(
6285            result,
6286            "Should highlight background for root file from different worktree"
6287        );
6288
6289        // Test 5: Single entry in subdirectory - should highlight background
6290        let child_file_dragged_selection = DraggedSelection {
6291            active_selection: SelectedEntry {
6292                worktree_id: worktree1_id,
6293                entry_id: child_file.id,
6294            },
6295            marked_selections: Arc::new([SelectedEntry {
6296                worktree_id: worktree1_id,
6297                entry_id: child_file.id,
6298            }]),
6299        };
6300
6301        let result = panel.should_highlight_background_for_selection_drag(
6302            &child_file_dragged_selection,
6303            root1_entry.id,
6304            cx,
6305        );
6306        assert!(
6307            result,
6308            "Should highlight background for file with non-empty parent path"
6309        );
6310    });
6311}
6312
6313#[gpui::test]
6314async fn test_hide_root(cx: &mut gpui::TestAppContext) {
6315    init_test(cx);
6316
6317    let fs = FakeFs::new(cx.executor());
6318    fs.insert_tree(
6319        "/root1",
6320        json!({
6321            "dir1": {
6322                "file1.txt": "content",
6323                "file2.txt": "content",
6324            },
6325            "dir2": {
6326                "file3.txt": "content",
6327            },
6328            "file4.txt": "content",
6329        }),
6330    )
6331    .await;
6332
6333    fs.insert_tree(
6334        "/root2",
6335        json!({
6336            "dir3": {
6337                "file5.txt": "content",
6338            },
6339            "file6.txt": "content",
6340        }),
6341    )
6342    .await;
6343
6344    // Test 1: Single worktree with hide_root = false
6345    {
6346        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6347        let workspace =
6348            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6349        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6350
6351        cx.update(|_, cx| {
6352            let settings = *ProjectPanelSettings::get_global(cx);
6353            ProjectPanelSettings::override_global(
6354                ProjectPanelSettings {
6355                    hide_root: false,
6356                    ..settings
6357                },
6358                cx,
6359            );
6360        });
6361
6362        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6363        cx.run_until_parked();
6364
6365        #[rustfmt::skip]
6366        assert_eq!(
6367            visible_entries_as_strings(&panel, 0..10, cx),
6368            &[
6369                "v root1",
6370                "    > dir1",
6371                "    > dir2",
6372                "      file4.txt",
6373            ],
6374            "With hide_root=false and single worktree, root should be visible"
6375        );
6376    }
6377
6378    // Test 2: Single worktree with hide_root = true
6379    {
6380        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6381        let workspace =
6382            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6383        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6384
6385        // Set hide_root to true
6386        cx.update(|_, cx| {
6387            let settings = *ProjectPanelSettings::get_global(cx);
6388            ProjectPanelSettings::override_global(
6389                ProjectPanelSettings {
6390                    hide_root: true,
6391                    ..settings
6392                },
6393                cx,
6394            );
6395        });
6396
6397        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6398        cx.run_until_parked();
6399
6400        assert_eq!(
6401            visible_entries_as_strings(&panel, 0..10, cx),
6402            &["> dir1", "> dir2", "  file4.txt",],
6403            "With hide_root=true and single worktree, root should be hidden"
6404        );
6405
6406        // Test expanding directories still works without root
6407        toggle_expand_dir(&panel, "root1/dir1", cx);
6408        assert_eq!(
6409            visible_entries_as_strings(&panel, 0..10, cx),
6410            &[
6411                "v dir1  <== selected",
6412                "      file1.txt",
6413                "      file2.txt",
6414                "> dir2",
6415                "  file4.txt",
6416            ],
6417            "Should be able to expand directories even when root is hidden"
6418        );
6419    }
6420
6421    // Test 3: Multiple worktrees with hide_root = true
6422    {
6423        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6424        let workspace =
6425            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6426        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6427
6428        // Set hide_root to true
6429        cx.update(|_, cx| {
6430            let settings = *ProjectPanelSettings::get_global(cx);
6431            ProjectPanelSettings::override_global(
6432                ProjectPanelSettings {
6433                    hide_root: true,
6434                    ..settings
6435                },
6436                cx,
6437            );
6438        });
6439
6440        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6441        cx.run_until_parked();
6442
6443        assert_eq!(
6444            visible_entries_as_strings(&panel, 0..10, cx),
6445            &[
6446                "v root1",
6447                "    > dir1",
6448                "    > dir2",
6449                "      file4.txt",
6450                "v root2",
6451                "    > dir3",
6452                "      file6.txt",
6453            ],
6454            "With hide_root=true and multiple worktrees, roots should still be visible"
6455        );
6456    }
6457
6458    // Test 4: Multiple worktrees with hide_root = false
6459    {
6460        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6461        let workspace =
6462            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6463        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6464
6465        cx.update(|_, cx| {
6466            let settings = *ProjectPanelSettings::get_global(cx);
6467            ProjectPanelSettings::override_global(
6468                ProjectPanelSettings {
6469                    hide_root: false,
6470                    ..settings
6471                },
6472                cx,
6473            );
6474        });
6475
6476        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6477        cx.run_until_parked();
6478
6479        assert_eq!(
6480            visible_entries_as_strings(&panel, 0..10, cx),
6481            &[
6482                "v root1",
6483                "    > dir1",
6484                "    > dir2",
6485                "      file4.txt",
6486                "v root2",
6487                "    > dir3",
6488                "      file6.txt",
6489            ],
6490            "With hide_root=false and multiple worktrees, roots should be visible"
6491        );
6492    }
6493}
6494
6495#[gpui::test]
6496async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
6497    init_test_with_editor(cx);
6498
6499    let fs = FakeFs::new(cx.executor());
6500    fs.insert_tree(
6501        "/root",
6502        json!({
6503            "file1.txt": "content of file1",
6504            "file2.txt": "content of file2",
6505            "dir1": {
6506                "file3.txt": "content of file3"
6507            }
6508        }),
6509    )
6510    .await;
6511
6512    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6513    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6514    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6515    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6516    cx.run_until_parked();
6517
6518    let file1_path = "root/file1.txt";
6519    let file2_path = "root/file2.txt";
6520    select_path_with_mark(&panel, file1_path, cx);
6521    select_path_with_mark(&panel, file2_path, cx);
6522
6523    panel.update_in(cx, |panel, window, cx| {
6524        panel.compare_marked_files(&CompareMarkedFiles, window, cx);
6525    });
6526    cx.executor().run_until_parked();
6527
6528    workspace
6529        .update(cx, |workspace, _, cx| {
6530            let active_items = workspace
6531                .panes()
6532                .iter()
6533                .filter_map(|pane| pane.read(cx).active_item())
6534                .collect::<Vec<_>>();
6535            assert_eq!(active_items.len(), 1);
6536            let diff_view = active_items
6537                .into_iter()
6538                .next()
6539                .unwrap()
6540                .downcast::<FileDiffView>()
6541                .expect("Open item should be an FileDiffView");
6542            assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
6543            assert_eq!(
6544                diff_view.tab_tooltip_text(cx).unwrap(),
6545                format!(
6546                    "{} ↔ {}",
6547                    rel_path(file1_path).display(PathStyle::local()),
6548                    rel_path(file2_path).display(PathStyle::local())
6549                )
6550            );
6551        })
6552        .unwrap();
6553
6554    let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
6555    let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
6556    let worktree_id = panel.update(cx, |panel, cx| {
6557        panel
6558            .project
6559            .read(cx)
6560            .worktrees(cx)
6561            .next()
6562            .unwrap()
6563            .read(cx)
6564            .id()
6565    });
6566
6567    let expected_entries = [
6568        SelectedEntry {
6569            worktree_id,
6570            entry_id: file1_entry_id,
6571        },
6572        SelectedEntry {
6573            worktree_id,
6574            entry_id: file2_entry_id,
6575        },
6576    ];
6577    panel.update(cx, |panel, _cx| {
6578        assert_eq!(
6579            &panel.marked_entries, &expected_entries,
6580            "Should keep marked entries after comparison"
6581        );
6582    });
6583
6584    panel.update(cx, |panel, cx| {
6585        panel.project.update(cx, |_, cx| {
6586            cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
6587        })
6588    });
6589
6590    panel.update(cx, |panel, _cx| {
6591        assert_eq!(
6592            &panel.marked_entries, &expected_entries,
6593            "Marked entries should persist after focusing back on the project panel"
6594        );
6595    });
6596}
6597
6598#[gpui::test]
6599async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
6600    init_test_with_editor(cx);
6601
6602    let fs = FakeFs::new(cx.executor());
6603    fs.insert_tree(
6604        "/root",
6605        json!({
6606            "file1.txt": "content of file1",
6607            "file2.txt": "content of file2",
6608            "dir1": {},
6609            "dir2": {
6610                "file3.txt": "content of file3"
6611            }
6612        }),
6613    )
6614    .await;
6615
6616    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6617    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6618    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6619    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6620    cx.run_until_parked();
6621
6622    // Test 1: When only one file is selected, there should be no compare option
6623    select_path(&panel, "root/file1.txt", cx);
6624
6625    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6626    assert_eq!(
6627        selected_files, None,
6628        "Should not have compare option when only one file is selected"
6629    );
6630
6631    // Test 2: When multiple files are selected, there should be a compare option
6632    select_path_with_mark(&panel, "root/file1.txt", cx);
6633    select_path_with_mark(&panel, "root/file2.txt", cx);
6634
6635    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6636    assert!(
6637        selected_files.is_some(),
6638        "Should have files selected for comparison"
6639    );
6640    if let Some((file1, file2)) = selected_files {
6641        assert!(
6642            file1.to_string_lossy().ends_with("file1.txt")
6643                && file2.to_string_lossy().ends_with("file2.txt"),
6644            "Should have file1.txt and file2.txt as the selected files when multi-selecting"
6645        );
6646    }
6647
6648    // Test 3: Selecting a directory shouldn't count as a comparable file
6649    select_path_with_mark(&panel, "root/dir1", cx);
6650
6651    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6652    assert!(
6653        selected_files.is_some(),
6654        "Directory selection should not affect comparable files"
6655    );
6656    if let Some((file1, file2)) = selected_files {
6657        assert!(
6658            file1.to_string_lossy().ends_with("file1.txt")
6659                && file2.to_string_lossy().ends_with("file2.txt"),
6660            "Selecting a directory should not affect the number of comparable files"
6661        );
6662    }
6663
6664    // Test 4: Selecting one more file
6665    select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
6666
6667    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6668    assert!(
6669        selected_files.is_some(),
6670        "Directory selection should not affect comparable files"
6671    );
6672    if let Some((file1, file2)) = selected_files {
6673        assert!(
6674            file1.to_string_lossy().ends_with("file2.txt")
6675                && file2.to_string_lossy().ends_with("file3.txt"),
6676            "Selecting a directory should not affect the number of comparable files"
6677        );
6678    }
6679}
6680
6681#[gpui::test]
6682async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
6683    init_test(cx);
6684
6685    let fs = FakeFs::new(cx.executor());
6686    fs.insert_tree(
6687        "/root",
6688        json!({
6689            ".hidden-file.txt": "hidden file content",
6690            "visible-file.txt": "visible file content",
6691            ".hidden-parent-dir": {
6692                "nested-dir": {
6693                    "file.txt": "file content",
6694                }
6695            },
6696            "visible-dir": {
6697                "file-in-visible.txt": "file content",
6698                "nested": {
6699                    ".hidden-nested-dir": {
6700                        ".double-hidden-dir": {
6701                            "deep-file-1.txt": "deep content 1",
6702                            "deep-file-2.txt": "deep content 2"
6703                        },
6704                        "hidden-nested-file-1.txt": "hidden nested 1",
6705                        "hidden-nested-file-2.txt": "hidden nested 2"
6706                    },
6707                    "visible-nested-file.txt": "visible nested content"
6708                }
6709            }
6710        }),
6711    )
6712    .await;
6713
6714    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6715    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6716    let cx = &mut VisualTestContext::from_window(*workspace, cx);
6717
6718    cx.update(|_, cx| {
6719        let settings = *ProjectPanelSettings::get_global(cx);
6720        ProjectPanelSettings::override_global(
6721            ProjectPanelSettings {
6722                hide_hidden: false,
6723                ..settings
6724            },
6725            cx,
6726        );
6727    });
6728
6729    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6730    cx.run_until_parked();
6731
6732    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
6733    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
6734    toggle_expand_dir(&panel, "root/visible-dir", cx);
6735    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
6736    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
6737    toggle_expand_dir(
6738        &panel,
6739        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
6740        cx,
6741    );
6742
6743    let expanded = [
6744        "v root",
6745        "    v .hidden-parent-dir",
6746        "        v nested-dir",
6747        "              file.txt",
6748        "    v visible-dir",
6749        "        v nested",
6750        "            v .hidden-nested-dir",
6751        "                v .double-hidden-dir  <== selected",
6752        "                      deep-file-1.txt",
6753        "                      deep-file-2.txt",
6754        "                  hidden-nested-file-1.txt",
6755        "                  hidden-nested-file-2.txt",
6756        "              visible-nested-file.txt",
6757        "          file-in-visible.txt",
6758        "      .hidden-file.txt",
6759        "      visible-file.txt",
6760    ];
6761
6762    assert_eq!(
6763        visible_entries_as_strings(&panel, 0..30, cx),
6764        &expanded,
6765        "With hide_hidden=false, contents of hidden nested directory should be visible"
6766    );
6767
6768    cx.update(|_, cx| {
6769        let settings = *ProjectPanelSettings::get_global(cx);
6770        ProjectPanelSettings::override_global(
6771            ProjectPanelSettings {
6772                hide_hidden: true,
6773                ..settings
6774            },
6775            cx,
6776        );
6777    });
6778
6779    panel.update_in(cx, |panel, window, cx| {
6780        panel.update_visible_entries(None, false, false, window, cx);
6781    });
6782    cx.run_until_parked();
6783
6784    assert_eq!(
6785        visible_entries_as_strings(&panel, 0..30, cx),
6786        &[
6787            "v root",
6788            "    v visible-dir",
6789            "        v nested",
6790            "              visible-nested-file.txt",
6791            "          file-in-visible.txt",
6792            "      visible-file.txt",
6793        ],
6794        "With hide_hidden=false, contents of hidden nested directory should be visible"
6795    );
6796
6797    panel.update_in(cx, |panel, window, cx| {
6798        let settings = *ProjectPanelSettings::get_global(cx);
6799        ProjectPanelSettings::override_global(
6800            ProjectPanelSettings {
6801                hide_hidden: false,
6802                ..settings
6803            },
6804            cx,
6805        );
6806        panel.update_visible_entries(None, false, false, window, cx);
6807    });
6808    cx.run_until_parked();
6809
6810    assert_eq!(
6811        visible_entries_as_strings(&panel, 0..30, cx),
6812        &expanded,
6813        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
6814    );
6815}
6816
6817fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6818    let path = rel_path(path);
6819    panel.update_in(cx, |panel, window, cx| {
6820        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6821            let worktree = worktree.read(cx);
6822            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6823                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6824                panel.update_visible_entries(
6825                    Some((worktree.id(), entry_id)),
6826                    false,
6827                    false,
6828                    window,
6829                    cx,
6830                );
6831                return;
6832            }
6833        }
6834        panic!("no worktree for path {:?}", path);
6835    });
6836    cx.run_until_parked();
6837}
6838
6839fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6840    let path = rel_path(path);
6841    panel.update(cx, |panel, cx| {
6842        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6843            let worktree = worktree.read(cx);
6844            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6845                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6846                let entry = crate::SelectedEntry {
6847                    worktree_id: worktree.id(),
6848                    entry_id,
6849                };
6850                if !panel.marked_entries.contains(&entry) {
6851                    panel.marked_entries.push(entry);
6852                }
6853                panel.state.selection = Some(entry);
6854                return;
6855            }
6856        }
6857        panic!("no worktree for path {:?}", path);
6858    });
6859}
6860
6861fn find_project_entry(
6862    panel: &Entity<ProjectPanel>,
6863    path: &str,
6864    cx: &mut VisualTestContext,
6865) -> Option<ProjectEntryId> {
6866    let path = rel_path(path);
6867    panel.update(cx, |panel, cx| {
6868        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6869            let worktree = worktree.read(cx);
6870            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6871                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6872            }
6873        }
6874        panic!("no worktree for path {path:?}");
6875    })
6876}
6877
6878fn visible_entries_as_strings(
6879    panel: &Entity<ProjectPanel>,
6880    range: Range<usize>,
6881    cx: &mut VisualTestContext,
6882) -> Vec<String> {
6883    let mut result = Vec::new();
6884    let mut project_entries = HashSet::default();
6885    let mut has_editor = false;
6886
6887    panel.update_in(cx, |panel, window, cx| {
6888        panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
6889            if details.is_editing {
6890                assert!(!has_editor, "duplicate editor entry");
6891                has_editor = true;
6892            } else {
6893                assert!(
6894                    project_entries.insert(project_entry),
6895                    "duplicate project entry {:?} {:?}",
6896                    project_entry,
6897                    details
6898                );
6899            }
6900
6901            let indent = "    ".repeat(details.depth);
6902            let icon = if details.kind.is_dir() {
6903                if details.is_expanded { "v " } else { "> " }
6904            } else {
6905                "  "
6906            };
6907            #[cfg(windows)]
6908            let filename = details.filename.replace("\\", "/");
6909            #[cfg(not(windows))]
6910            let filename = details.filename;
6911            let name = if details.is_editing {
6912                format!("[EDITOR: '{}']", filename)
6913            } else if details.is_processing {
6914                format!("[PROCESSING: '{}']", filename)
6915            } else {
6916                filename
6917            };
6918            let selected = if details.is_selected {
6919                "  <== selected"
6920            } else {
6921                ""
6922            };
6923            let marked = if details.is_marked {
6924                "  <== marked"
6925            } else {
6926                ""
6927            };
6928
6929            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6930        });
6931    });
6932
6933    result
6934}
6935
6936fn init_test(cx: &mut TestAppContext) {
6937    cx.update(|cx| {
6938        let settings_store = SettingsStore::test(cx);
6939        cx.set_global(settings_store);
6940        init_settings(cx);
6941        theme::init(theme::LoadThemes::JustBase, cx);
6942        language::init(cx);
6943        editor::init_settings(cx);
6944        crate::init(cx);
6945        workspace::init_settings(cx);
6946        client::init_settings(cx);
6947        Project::init_settings(cx);
6948
6949        cx.update_global::<SettingsStore, _>(|store, cx| {
6950            store.update_user_settings(cx, |settings| {
6951                settings
6952                    .project_panel
6953                    .get_or_insert_default()
6954                    .auto_fold_dirs = Some(false);
6955                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
6956            });
6957        });
6958    });
6959}
6960
6961fn init_test_with_editor(cx: &mut TestAppContext) {
6962    cx.update(|cx| {
6963        let app_state = AppState::test(cx);
6964        theme::init(theme::LoadThemes::JustBase, cx);
6965        init_settings(cx);
6966        language::init(cx);
6967        editor::init(cx);
6968        crate::init(cx);
6969        workspace::init(app_state, cx);
6970        Project::init_settings(cx);
6971
6972        cx.update_global::<SettingsStore, _>(|store, cx| {
6973            store.update_user_settings(cx, |settings| {
6974                settings
6975                    .project_panel
6976                    .get_or_insert_default()
6977                    .auto_fold_dirs = Some(false);
6978                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
6979            });
6980        });
6981    });
6982}
6983
6984fn ensure_single_file_is_opened(
6985    window: &WindowHandle<Workspace>,
6986    expected_path: &str,
6987    cx: &mut TestAppContext,
6988) {
6989    window
6990        .update(cx, |workspace, _, cx| {
6991            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6992            assert_eq!(worktrees.len(), 1);
6993            let worktree_id = worktrees[0].read(cx).id();
6994
6995            let open_project_paths = workspace
6996                .panes()
6997                .iter()
6998                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6999                .collect::<Vec<_>>();
7000            assert_eq!(
7001                open_project_paths,
7002                vec![ProjectPath {
7003                    worktree_id,
7004                    path: Arc::from(rel_path(expected_path))
7005                }],
7006                "Should have opened file, selected in project panel"
7007            );
7008        })
7009        .unwrap();
7010}
7011
7012fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
7013    assert!(
7014        !cx.has_pending_prompt(),
7015        "Should have no prompts before the deletion"
7016    );
7017    panel.update_in(cx, |panel, window, cx| {
7018        panel.delete(&Delete { skip_prompt: false }, window, cx)
7019    });
7020    assert!(
7021        cx.has_pending_prompt(),
7022        "Should have a prompt after the deletion"
7023    );
7024    cx.simulate_prompt_answer("Delete");
7025    assert!(
7026        !cx.has_pending_prompt(),
7027        "Should have no prompts after prompt was replied to"
7028    );
7029    cx.executor().run_until_parked();
7030}
7031
7032fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
7033    assert!(
7034        !cx.has_pending_prompt(),
7035        "Should have no prompts before the deletion"
7036    );
7037    panel.update_in(cx, |panel, window, cx| {
7038        panel.delete(&Delete { skip_prompt: true }, window, cx)
7039    });
7040    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
7041    cx.executor().run_until_parked();
7042}
7043
7044fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
7045    assert!(
7046        !cx.has_pending_prompt(),
7047        "Should have no prompts after deletion operation closes the file"
7048    );
7049    workspace
7050        .read_with(cx, |workspace, cx| {
7051            let open_project_paths = workspace
7052                .panes()
7053                .iter()
7054                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7055                .collect::<Vec<_>>();
7056            assert!(
7057                open_project_paths.is_empty(),
7058                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
7059            );
7060        })
7061        .unwrap();
7062}
7063
7064struct TestProjectItemView {
7065    focus_handle: FocusHandle,
7066    path: ProjectPath,
7067}
7068
7069struct TestProjectItem {
7070    path: ProjectPath,
7071}
7072
7073impl project::ProjectItem for TestProjectItem {
7074    fn try_open(
7075        _project: &Entity<Project>,
7076        path: &ProjectPath,
7077        cx: &mut App,
7078    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
7079        let path = path.clone();
7080        Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
7081    }
7082
7083    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
7084        None
7085    }
7086
7087    fn project_path(&self, _: &App) -> Option<ProjectPath> {
7088        Some(self.path.clone())
7089    }
7090
7091    fn is_dirty(&self) -> bool {
7092        false
7093    }
7094}
7095
7096impl ProjectItem for TestProjectItemView {
7097    type Item = TestProjectItem;
7098
7099    fn for_project_item(
7100        _: Entity<Project>,
7101        _: Option<&Pane>,
7102        project_item: Entity<Self::Item>,
7103        _: &mut Window,
7104        cx: &mut Context<Self>,
7105    ) -> Self
7106    where
7107        Self: Sized,
7108    {
7109        Self {
7110            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
7111            focus_handle: cx.focus_handle(),
7112        }
7113    }
7114}
7115
7116impl Item for TestProjectItemView {
7117    type Event = ();
7118
7119    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
7120        "Test".into()
7121    }
7122}
7123
7124impl EventEmitter<()> for TestProjectItemView {}
7125
7126impl Focusable for TestProjectItemView {
7127    fn focus_handle(&self, _: &App) -> FocusHandle {
7128        self.focus_handle.clone()
7129    }
7130}
7131
7132impl Render for TestProjectItemView {
7133    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
7134        Empty
7135    }
7136}