project_panel_tests.rs

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