project_panel_tests.rs

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