project_panel_tests.rs

   1use super::*;
   2use collections::HashSet;
   3use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
   4use pretty_assertions::assert_eq;
   5use project::{FakeFs, WorktreeSettings};
   6use serde_json::json;
   7use settings::SettingsStore;
   8use std::path::{Path, PathBuf};
   9use util::{path, separator};
  10use workspace::{
  11    item::{Item, ProjectItem},
  12    register_project_item, AppState,
  13};
  14
  15#[gpui::test]
  16async fn test_visible_list(cx: &mut gpui::TestAppContext) {
  17    init_test(cx);
  18
  19    let fs = FakeFs::new(cx.executor().clone());
  20    fs.insert_tree(
  21        "/root1",
  22        json!({
  23            ".dockerignore": "",
  24            ".git": {
  25                "HEAD": "",
  26            },
  27            "a": {
  28                "0": { "q": "", "r": "", "s": "" },
  29                "1": { "t": "", "u": "" },
  30                "2": { "v": "", "w": "", "x": "", "y": "" },
  31            },
  32            "b": {
  33                "3": { "Q": "" },
  34                "4": { "R": "", "S": "", "T": "", "U": "" },
  35            },
  36            "C": {
  37                "5": {},
  38                "6": { "V": "", "W": "" },
  39                "7": { "X": "" },
  40                "8": { "Y": {}, "Z": "" }
  41            }
  42        }),
  43    )
  44    .await;
  45    fs.insert_tree(
  46        "/root2",
  47        json!({
  48            "d": {
  49                "9": ""
  50            },
  51            "e": {}
  52        }),
  53    )
  54    .await;
  55
  56    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
  57    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
  58    let cx = &mut VisualTestContext::from_window(*workspace, cx);
  59    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
  60    assert_eq!(
  61        visible_entries_as_strings(&panel, 0..50, cx),
  62        &[
  63            "v root1",
  64            "    > .git",
  65            "    > a",
  66            "    > b",
  67            "    > C",
  68            "      .dockerignore",
  69            "v root2",
  70            "    > d",
  71            "    > e",
  72        ]
  73    );
  74
  75    toggle_expand_dir(&panel, "root1/b", cx);
  76    assert_eq!(
  77        visible_entries_as_strings(&panel, 0..50, cx),
  78        &[
  79            "v root1",
  80            "    > .git",
  81            "    > a",
  82            "    v b  <== selected",
  83            "        > 3",
  84            "        > 4",
  85            "    > C",
  86            "      .dockerignore",
  87            "v root2",
  88            "    > d",
  89            "    > e",
  90        ]
  91    );
  92
  93    assert_eq!(
  94        visible_entries_as_strings(&panel, 6..9, cx),
  95        &[
  96            //
  97            "    > C",
  98            "      .dockerignore",
  99            "v root2",
 100        ]
 101    );
 102}
 103
 104#[gpui::test]
 105async fn test_opening_file(cx: &mut gpui::TestAppContext) {
 106    init_test_with_editor(cx);
 107
 108    let fs = FakeFs::new(cx.executor().clone());
 109    fs.insert_tree(
 110        path!("/src"),
 111        json!({
 112            "test": {
 113                "first.rs": "// First Rust file",
 114                "second.rs": "// Second Rust file",
 115                "third.rs": "// Third Rust file",
 116            }
 117        }),
 118    )
 119    .await;
 120
 121    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
 122    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 123    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 124    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 125
 126    toggle_expand_dir(&panel, "src/test", cx);
 127    select_path(&panel, "src/test/first.rs", cx);
 128    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 129    cx.executor().run_until_parked();
 130    assert_eq!(
 131        visible_entries_as_strings(&panel, 0..10, cx),
 132        &[
 133            "v src",
 134            "    v test",
 135            "          first.rs  <== selected  <== marked",
 136            "          second.rs",
 137            "          third.rs"
 138        ]
 139    );
 140    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
 141
 142    select_path(&panel, "src/test/second.rs", cx);
 143    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
 144    cx.executor().run_until_parked();
 145    assert_eq!(
 146        visible_entries_as_strings(&panel, 0..10, cx),
 147        &[
 148            "v src",
 149            "    v test",
 150            "          first.rs",
 151            "          second.rs  <== selected  <== marked",
 152            "          third.rs"
 153        ]
 154    );
 155    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
 156}
 157
 158#[gpui::test]
 159async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
 160    init_test(cx);
 161    cx.update(|cx| {
 162        cx.update_global::<SettingsStore, _>(|store, cx| {
 163            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
 164                worktree_settings.file_scan_exclusions =
 165                    Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
 166            });
 167        });
 168    });
 169
 170    let fs = FakeFs::new(cx.background_executor.clone());
 171    fs.insert_tree(
 172        "/root1",
 173        json!({
 174            ".dockerignore": "",
 175            ".git": {
 176                "HEAD": "",
 177            },
 178            "a": {
 179                "0": { "q": "", "r": "", "s": "" },
 180                "1": { "t": "", "u": "" },
 181                "2": { "v": "", "w": "", "x": "", "y": "" },
 182            },
 183            "b": {
 184                "3": { "Q": "" },
 185                "4": { "R": "", "S": "", "T": "", "U": "" },
 186            },
 187            "C": {
 188                "5": {},
 189                "6": { "V": "", "W": "" },
 190                "7": { "X": "" },
 191                "8": { "Y": {}, "Z": "" }
 192            }
 193        }),
 194    )
 195    .await;
 196    fs.insert_tree(
 197        "/root2",
 198        json!({
 199            "d": {
 200                "4": ""
 201            },
 202            "e": {}
 203        }),
 204    )
 205    .await;
 206
 207    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 208    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 209    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 210    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 211    assert_eq!(
 212        visible_entries_as_strings(&panel, 0..50, cx),
 213        &[
 214            "v root1",
 215            "    > a",
 216            "    > b",
 217            "    > C",
 218            "      .dockerignore",
 219            "v root2",
 220            "    > d",
 221            "    > e",
 222        ]
 223    );
 224
 225    toggle_expand_dir(&panel, "root1/b", cx);
 226    assert_eq!(
 227        visible_entries_as_strings(&panel, 0..50, cx),
 228        &[
 229            "v root1",
 230            "    > a",
 231            "    v b  <== selected",
 232            "        > 3",
 233            "    > C",
 234            "      .dockerignore",
 235            "v root2",
 236            "    > d",
 237            "    > e",
 238        ]
 239    );
 240
 241    toggle_expand_dir(&panel, "root2/d", cx);
 242    assert_eq!(
 243        visible_entries_as_strings(&panel, 0..50, cx),
 244        &[
 245            "v root1",
 246            "    > a",
 247            "    v b",
 248            "        > 3",
 249            "    > C",
 250            "      .dockerignore",
 251            "v root2",
 252            "    v d  <== selected",
 253            "    > e",
 254        ]
 255    );
 256
 257    toggle_expand_dir(&panel, "root2/e", cx);
 258    assert_eq!(
 259        visible_entries_as_strings(&panel, 0..50, cx),
 260        &[
 261            "v root1",
 262            "    > a",
 263            "    v b",
 264            "        > 3",
 265            "    > C",
 266            "      .dockerignore",
 267            "v root2",
 268            "    v d",
 269            "    v e  <== selected",
 270        ]
 271    );
 272}
 273
 274#[gpui::test]
 275async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
 276    init_test(cx);
 277
 278    let fs = FakeFs::new(cx.executor().clone());
 279    fs.insert_tree(
 280        path!("/root1"),
 281        json!({
 282            "dir_1": {
 283                "nested_dir_1": {
 284                    "nested_dir_2": {
 285                        "nested_dir_3": {
 286                            "file_a.java": "// File contents",
 287                            "file_b.java": "// File contents",
 288                            "file_c.java": "// File contents",
 289                            "nested_dir_4": {
 290                                "nested_dir_5": {
 291                                    "file_d.java": "// File contents",
 292                                }
 293                            }
 294                        }
 295                    }
 296                }
 297            }
 298        }),
 299    )
 300    .await;
 301    fs.insert_tree(
 302        path!("/root2"),
 303        json!({
 304            "dir_2": {
 305                "file_1.java": "// File contents",
 306            }
 307        }),
 308    )
 309    .await;
 310
 311    let project = Project::test(
 312        fs.clone(),
 313        [path!("/root1").as_ref(), path!("/root2").as_ref()],
 314        cx,
 315    )
 316    .await;
 317    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 318    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 319    cx.update(|_, cx| {
 320        let settings = *ProjectPanelSettings::get_global(cx);
 321        ProjectPanelSettings::override_global(
 322            ProjectPanelSettings {
 323                auto_fold_dirs: true,
 324                ..settings
 325            },
 326            cx,
 327        );
 328    });
 329    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
 330    assert_eq!(
 331        visible_entries_as_strings(&panel, 0..10, cx),
 332        &[
 333            separator!("v root1"),
 334            separator!("    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
 335            separator!("v root2"),
 336            separator!("    > dir_2"),
 337        ]
 338    );
 339
 340    toggle_expand_dir(
 341        &panel,
 342        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
 343        cx,
 344    );
 345    assert_eq!(
 346        visible_entries_as_strings(&panel, 0..10, cx),
 347        &[
 348            separator!("v root1"),
 349            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected"),
 350            separator!("        > nested_dir_4/nested_dir_5"),
 351            separator!("          file_a.java"),
 352            separator!("          file_b.java"),
 353            separator!("          file_c.java"),
 354            separator!("v root2"),
 355            separator!("    > dir_2"),
 356        ]
 357    );
 358
 359    toggle_expand_dir(
 360        &panel,
 361        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
 362        cx,
 363    );
 364    assert_eq!(
 365        visible_entries_as_strings(&panel, 0..10, cx),
 366        &[
 367            separator!("v root1"),
 368            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
 369            separator!("        v nested_dir_4/nested_dir_5  <== selected"),
 370            separator!("              file_d.java"),
 371            separator!("          file_a.java"),
 372            separator!("          file_b.java"),
 373            separator!("          file_c.java"),
 374            separator!("v root2"),
 375            separator!("    > dir_2"),
 376        ]
 377    );
 378    toggle_expand_dir(&panel, "root2/dir_2", cx);
 379    assert_eq!(
 380        visible_entries_as_strings(&panel, 0..10, cx),
 381        &[
 382            separator!("v root1"),
 383            separator!("    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
 384            separator!("        v nested_dir_4/nested_dir_5"),
 385            separator!("              file_d.java"),
 386            separator!("          file_a.java"),
 387            separator!("          file_b.java"),
 388            separator!("          file_c.java"),
 389            separator!("v root2"),
 390            separator!("    v dir_2  <== selected"),
 391            separator!("          file_1.java"),
 392        ]
 393    );
 394}
 395
 396#[gpui::test(iterations = 30)]
 397async fn test_editing_files(cx: &mut gpui::TestAppContext) {
 398    init_test(cx);
 399
 400    let fs = FakeFs::new(cx.executor().clone());
 401    fs.insert_tree(
 402        "/root1",
 403        json!({
 404            ".dockerignore": "",
 405            ".git": {
 406                "HEAD": "",
 407            },
 408            "a": {
 409                "0": { "q": "", "r": "", "s": "" },
 410                "1": { "t": "", "u": "" },
 411                "2": { "v": "", "w": "", "x": "", "y": "" },
 412            },
 413            "b": {
 414                "3": { "Q": "" },
 415                "4": { "R": "", "S": "", "T": "", "U": "" },
 416            },
 417            "C": {
 418                "5": {},
 419                "6": { "V": "", "W": "" },
 420                "7": { "X": "" },
 421                "8": { "Y": {}, "Z": "" }
 422            }
 423        }),
 424    )
 425    .await;
 426    fs.insert_tree(
 427        "/root2",
 428        json!({
 429            "d": {
 430                "9": ""
 431            },
 432            "e": {}
 433        }),
 434    )
 435    .await;
 436
 437    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 438    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 439    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 440    let panel = workspace
 441        .update(cx, |workspace, window, cx| {
 442            let panel = ProjectPanel::new(workspace, window, cx);
 443            workspace.add_panel(panel.clone(), window, cx);
 444            panel
 445        })
 446        .unwrap();
 447
 448    select_path(&panel, "root1", cx);
 449    assert_eq!(
 450        visible_entries_as_strings(&panel, 0..10, cx),
 451        &[
 452            "v root1  <== selected",
 453            "    > .git",
 454            "    > a",
 455            "    > b",
 456            "    > C",
 457            "      .dockerignore",
 458            "v root2",
 459            "    > d",
 460            "    > e",
 461        ]
 462    );
 463
 464    // Add a file with the root folder selected. The filename editor is placed
 465    // before the first file in the root folder.
 466    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 467    panel.update_in(cx, |panel, window, cx| {
 468        assert!(panel.filename_editor.read(cx).is_focused(window));
 469    });
 470    assert_eq!(
 471        visible_entries_as_strings(&panel, 0..10, cx),
 472        &[
 473            "v root1",
 474            "    > .git",
 475            "    > a",
 476            "    > b",
 477            "    > C",
 478            "      [EDITOR: '']  <== selected",
 479            "      .dockerignore",
 480            "v root2",
 481            "    > d",
 482            "    > e",
 483        ]
 484    );
 485
 486    let confirm = panel.update_in(cx, |panel, window, cx| {
 487        panel.filename_editor.update(cx, |editor, cx| {
 488            editor.set_text("the-new-filename", window, cx)
 489        });
 490        panel.confirm_edit(window, cx).unwrap()
 491    });
 492    assert_eq!(
 493        visible_entries_as_strings(&panel, 0..10, cx),
 494        &[
 495            "v root1",
 496            "    > .git",
 497            "    > a",
 498            "    > b",
 499            "    > C",
 500            "      [PROCESSING: 'the-new-filename']  <== selected",
 501            "      .dockerignore",
 502            "v root2",
 503            "    > d",
 504            "    > e",
 505        ]
 506    );
 507
 508    confirm.await.unwrap();
 509    assert_eq!(
 510        visible_entries_as_strings(&panel, 0..10, cx),
 511        &[
 512            "v root1",
 513            "    > .git",
 514            "    > a",
 515            "    > b",
 516            "    > C",
 517            "      .dockerignore",
 518            "      the-new-filename  <== selected  <== marked",
 519            "v root2",
 520            "    > d",
 521            "    > e",
 522        ]
 523    );
 524
 525    select_path(&panel, "root1/b", cx);
 526    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 527    assert_eq!(
 528        visible_entries_as_strings(&panel, 0..10, cx),
 529        &[
 530            "v root1",
 531            "    > .git",
 532            "    > a",
 533            "    v b",
 534            "        > 3",
 535            "        > 4",
 536            "          [EDITOR: '']  <== selected",
 537            "    > C",
 538            "      .dockerignore",
 539            "      the-new-filename",
 540        ]
 541    );
 542
 543    panel
 544        .update_in(cx, |panel, window, cx| {
 545            panel.filename_editor.update(cx, |editor, cx| {
 546                editor.set_text("another-filename.txt", window, cx)
 547            });
 548            panel.confirm_edit(window, cx).unwrap()
 549        })
 550        .await
 551        .unwrap();
 552    assert_eq!(
 553        visible_entries_as_strings(&panel, 0..10, cx),
 554        &[
 555            "v root1",
 556            "    > .git",
 557            "    > a",
 558            "    v b",
 559            "        > 3",
 560            "        > 4",
 561            "          another-filename.txt  <== selected  <== marked",
 562            "    > C",
 563            "      .dockerignore",
 564            "      the-new-filename",
 565        ]
 566    );
 567
 568    select_path(&panel, "root1/b/another-filename.txt", cx);
 569    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 570    assert_eq!(
 571        visible_entries_as_strings(&panel, 0..10, cx),
 572        &[
 573            "v root1",
 574            "    > .git",
 575            "    > a",
 576            "    v b",
 577            "        > 3",
 578            "        > 4",
 579            "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
 580            "    > C",
 581            "      .dockerignore",
 582            "      the-new-filename",
 583        ]
 584    );
 585
 586    let confirm = panel.update_in(cx, |panel, window, cx| {
 587        panel.filename_editor.update(cx, |editor, cx| {
 588            let file_name_selections = editor.selections.all::<usize>(cx);
 589            assert_eq!(
 590                file_name_selections.len(),
 591                1,
 592                "File editing should have a single selection, but got: {file_name_selections:?}"
 593            );
 594            let file_name_selection = &file_name_selections[0];
 595            assert_eq!(
 596                file_name_selection.start, 0,
 597                "Should select the file name from the start"
 598            );
 599            assert_eq!(
 600                file_name_selection.end,
 601                "another-filename".len(),
 602                "Should not select file extension"
 603            );
 604
 605            editor.set_text("a-different-filename.tar.gz", window, cx)
 606        });
 607        panel.confirm_edit(window, cx).unwrap()
 608    });
 609    assert_eq!(
 610        visible_entries_as_strings(&panel, 0..10, cx),
 611        &[
 612            "v root1",
 613            "    > .git",
 614            "    > a",
 615            "    v b",
 616            "        > 3",
 617            "        > 4",
 618            "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
 619            "    > C",
 620            "      .dockerignore",
 621            "      the-new-filename",
 622        ]
 623    );
 624
 625    confirm.await.unwrap();
 626    assert_eq!(
 627        visible_entries_as_strings(&panel, 0..10, cx),
 628        &[
 629            "v root1",
 630            "    > .git",
 631            "    > a",
 632            "    v b",
 633            "        > 3",
 634            "        > 4",
 635            "          a-different-filename.tar.gz  <== selected",
 636            "    > C",
 637            "      .dockerignore",
 638            "      the-new-filename",
 639        ]
 640    );
 641
 642    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
 643    assert_eq!(
 644        visible_entries_as_strings(&panel, 0..10, cx),
 645        &[
 646            "v root1",
 647            "    > .git",
 648            "    > a",
 649            "    v b",
 650            "        > 3",
 651            "        > 4",
 652            "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
 653            "    > C",
 654            "      .dockerignore",
 655            "      the-new-filename",
 656        ]
 657    );
 658
 659    panel.update_in(cx, |panel, window, cx| {
 660            panel.filename_editor.update(cx, |editor, cx| {
 661                let file_name_selections = editor.selections.all::<usize>(cx);
 662                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
 663                let file_name_selection = &file_name_selections[0];
 664                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
 665                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..");
 666
 667            });
 668            panel.cancel(&menu::Cancel, window, cx)
 669        });
 670
 671    panel.update_in(cx, |panel, window, cx| {
 672        panel.new_directory(&NewDirectory, window, cx)
 673    });
 674    assert_eq!(
 675        visible_entries_as_strings(&panel, 0..10, cx),
 676        &[
 677            "v root1",
 678            "    > .git",
 679            "    > a",
 680            "    v b",
 681            "        > 3",
 682            "        > 4",
 683            "        > [EDITOR: '']  <== selected",
 684            "          a-different-filename.tar.gz",
 685            "    > C",
 686            "      .dockerignore",
 687        ]
 688    );
 689
 690    let confirm = panel.update_in(cx, |panel, window, cx| {
 691        panel
 692            .filename_editor
 693            .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
 694        panel.confirm_edit(window, cx).unwrap()
 695    });
 696    panel.update_in(cx, |panel, window, cx| {
 697        panel.select_next(&Default::default(), window, cx)
 698    });
 699    assert_eq!(
 700        visible_entries_as_strings(&panel, 0..10, cx),
 701        &[
 702            "v root1",
 703            "    > .git",
 704            "    > a",
 705            "    v b",
 706            "        > 3",
 707            "        > 4",
 708            "        > [PROCESSING: 'new-dir']",
 709            "          a-different-filename.tar.gz  <== selected",
 710            "    > C",
 711            "      .dockerignore",
 712        ]
 713    );
 714
 715    confirm.await.unwrap();
 716    assert_eq!(
 717        visible_entries_as_strings(&panel, 0..10, cx),
 718        &[
 719            "v root1",
 720            "    > .git",
 721            "    > a",
 722            "    v b",
 723            "        > 3",
 724            "        > 4",
 725            "        > new-dir",
 726            "          a-different-filename.tar.gz  <== selected",
 727            "    > C",
 728            "      .dockerignore",
 729        ]
 730    );
 731
 732    panel.update_in(cx, |panel, window, cx| {
 733        panel.rename(&Default::default(), window, cx)
 734    });
 735    assert_eq!(
 736        visible_entries_as_strings(&panel, 0..10, cx),
 737        &[
 738            "v root1",
 739            "    > .git",
 740            "    > a",
 741            "    v b",
 742            "        > 3",
 743            "        > 4",
 744            "        > new-dir",
 745            "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
 746            "    > C",
 747            "      .dockerignore",
 748        ]
 749    );
 750
 751    // Dismiss the rename editor when it loses focus.
 752    workspace.update(cx, |_, window, _| window.blur()).unwrap();
 753    assert_eq!(
 754        visible_entries_as_strings(&panel, 0..10, cx),
 755        &[
 756            "v root1",
 757            "    > .git",
 758            "    > a",
 759            "    v b",
 760            "        > 3",
 761            "        > 4",
 762            "        > new-dir",
 763            "          a-different-filename.tar.gz  <== selected",
 764            "    > C",
 765            "      .dockerignore",
 766        ]
 767    );
 768}
 769
 770#[gpui::test(iterations = 10)]
 771async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
 772    init_test(cx);
 773
 774    let fs = FakeFs::new(cx.executor().clone());
 775    fs.insert_tree(
 776        "/root1",
 777        json!({
 778            ".dockerignore": "",
 779            ".git": {
 780                "HEAD": "",
 781            },
 782            "a": {
 783                "0": { "q": "", "r": "", "s": "" },
 784                "1": { "t": "", "u": "" },
 785                "2": { "v": "", "w": "", "x": "", "y": "" },
 786            },
 787            "b": {
 788                "3": { "Q": "" },
 789                "4": { "R": "", "S": "", "T": "", "U": "" },
 790            },
 791            "C": {
 792                "5": {},
 793                "6": { "V": "", "W": "" },
 794                "7": { "X": "" },
 795                "8": { "Y": {}, "Z": "" }
 796            }
 797        }),
 798    )
 799    .await;
 800    fs.insert_tree(
 801        "/root2",
 802        json!({
 803            "d": {
 804                "9": ""
 805            },
 806            "e": {}
 807        }),
 808    )
 809    .await;
 810
 811    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
 812    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 813    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 814    let panel = workspace
 815        .update(cx, |workspace, window, cx| {
 816            let panel = ProjectPanel::new(workspace, window, cx);
 817            workspace.add_panel(panel.clone(), window, cx);
 818            panel
 819        })
 820        .unwrap();
 821
 822    select_path(&panel, "root1", cx);
 823    assert_eq!(
 824        visible_entries_as_strings(&panel, 0..10, cx),
 825        &[
 826            "v root1  <== selected",
 827            "    > .git",
 828            "    > a",
 829            "    > b",
 830            "    > C",
 831            "      .dockerignore",
 832            "v root2",
 833            "    > d",
 834            "    > e",
 835        ]
 836    );
 837
 838    // Add a file with the root folder selected. The filename editor is placed
 839    // before the first file in the root folder.
 840    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 841    panel.update_in(cx, |panel, window, cx| {
 842        assert!(panel.filename_editor.read(cx).is_focused(window));
 843    });
 844    assert_eq!(
 845        visible_entries_as_strings(&panel, 0..10, cx),
 846        &[
 847            "v root1",
 848            "    > .git",
 849            "    > a",
 850            "    > b",
 851            "    > C",
 852            "      [EDITOR: '']  <== selected",
 853            "      .dockerignore",
 854            "v root2",
 855            "    > d",
 856            "    > e",
 857        ]
 858    );
 859
 860    let confirm = panel.update_in(cx, |panel, window, cx| {
 861        panel.filename_editor.update(cx, |editor, cx| {
 862            editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
 863        });
 864        panel.confirm_edit(window, cx).unwrap()
 865    });
 866
 867    assert_eq!(
 868        visible_entries_as_strings(&panel, 0..10, cx),
 869        &[
 870            "v root1",
 871            "    > .git",
 872            "    > a",
 873            "    > b",
 874            "    > C",
 875            "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
 876            "      .dockerignore",
 877            "v root2",
 878            "    > d",
 879            "    > e",
 880        ]
 881    );
 882
 883    confirm.await.unwrap();
 884    assert_eq!(
 885        visible_entries_as_strings(&panel, 0..13, cx),
 886        &[
 887            "v root1",
 888            "    > .git",
 889            "    > a",
 890            "    > b",
 891            "    v bdir1",
 892            "        v dir2",
 893            "              the-new-filename  <== selected  <== marked",
 894            "    > C",
 895            "      .dockerignore",
 896            "v root2",
 897            "    > d",
 898            "    > e",
 899        ]
 900    );
 901}
 902
 903#[gpui::test]
 904async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
 905    init_test(cx);
 906
 907    let fs = FakeFs::new(cx.executor().clone());
 908    fs.insert_tree(
 909        path!("/root1"),
 910        json!({
 911            ".dockerignore": "",
 912            ".git": {
 913                "HEAD": "",
 914            },
 915        }),
 916    )
 917    .await;
 918
 919    let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
 920    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
 921    let cx = &mut VisualTestContext::from_window(*workspace, cx);
 922    let panel = workspace
 923        .update(cx, |workspace, window, cx| {
 924            let panel = ProjectPanel::new(workspace, window, cx);
 925            workspace.add_panel(panel.clone(), window, cx);
 926            panel
 927        })
 928        .unwrap();
 929
 930    select_path(&panel, "root1", cx);
 931    assert_eq!(
 932        visible_entries_as_strings(&panel, 0..10, cx),
 933        &["v root1  <== selected", "    > .git", "      .dockerignore",]
 934    );
 935
 936    // Add a file with the root folder selected. The filename editor is placed
 937    // before the first file in the root folder.
 938    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 939    panel.update_in(cx, |panel, window, cx| {
 940        assert!(panel.filename_editor.read(cx).is_focused(window));
 941    });
 942    assert_eq!(
 943        visible_entries_as_strings(&panel, 0..10, cx),
 944        &[
 945            "v root1",
 946            "    > .git",
 947            "      [EDITOR: '']  <== selected",
 948            "      .dockerignore",
 949        ]
 950    );
 951
 952    let confirm = panel.update_in(cx, |panel, window, cx| {
 953        // If we want to create a subdirectory, there should be no prefix slash.
 954        panel
 955            .filename_editor
 956            .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
 957        panel.confirm_edit(window, cx).unwrap()
 958    });
 959
 960    assert_eq!(
 961        visible_entries_as_strings(&panel, 0..10, cx),
 962        &[
 963            "v root1",
 964            "    > .git",
 965            "      [PROCESSING: 'new_dir/']  <== selected",
 966            "      .dockerignore",
 967        ]
 968    );
 969
 970    confirm.await.unwrap();
 971    assert_eq!(
 972        visible_entries_as_strings(&panel, 0..10, cx),
 973        &[
 974            "v root1",
 975            "    > .git",
 976            "    v new_dir  <== selected",
 977            "      .dockerignore",
 978        ]
 979    );
 980
 981    // Test filename with whitespace
 982    select_path(&panel, "root1", cx);
 983    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
 984    let confirm = panel.update_in(cx, |panel, window, cx| {
 985        // If we want to create a subdirectory, there should be no prefix slash.
 986        panel
 987            .filename_editor
 988            .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
 989        panel.confirm_edit(window, cx).unwrap()
 990    });
 991    confirm.await.unwrap();
 992    assert_eq!(
 993        visible_entries_as_strings(&panel, 0..10, cx),
 994        &[
 995            "v root1",
 996            "    > .git",
 997            "    v new dir 2  <== selected",
 998            "    v new_dir",
 999            "      .dockerignore",
1000        ]
1001    );
1002
1003    // Test filename ends with "\"
1004    #[cfg(target_os = "windows")]
1005    {
1006        select_path(&panel, "root1", cx);
1007        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1008        let confirm = panel.update_in(cx, |panel, window, cx| {
1009            // If we want to create a subdirectory, there should be no prefix slash.
1010            panel
1011                .filename_editor
1012                .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
1013            panel.confirm_edit(window, cx).unwrap()
1014        });
1015        confirm.await.unwrap();
1016        assert_eq!(
1017            visible_entries_as_strings(&panel, 0..10, cx),
1018            &[
1019                "v root1",
1020                "    > .git",
1021                "    v new dir 2",
1022                "    v new_dir",
1023                "    v new_dir_3  <== selected",
1024                "      .dockerignore",
1025            ]
1026        );
1027    }
1028}
1029
1030#[gpui::test]
1031async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1032    init_test(cx);
1033
1034    let fs = FakeFs::new(cx.executor().clone());
1035    fs.insert_tree(
1036        "/root1",
1037        json!({
1038            "one.two.txt": "",
1039            "one.txt": ""
1040        }),
1041    )
1042    .await;
1043
1044    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1045    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1046    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1047    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1048
1049    panel.update_in(cx, |panel, window, cx| {
1050        panel.select_next(&Default::default(), window, cx);
1051        panel.select_next(&Default::default(), window, cx);
1052    });
1053
1054    assert_eq!(
1055        visible_entries_as_strings(&panel, 0..50, cx),
1056        &[
1057            //
1058            "v root1",
1059            "      one.txt  <== selected",
1060            "      one.two.txt",
1061        ]
1062    );
1063
1064    // Regression test - file name is created correctly when
1065    // the copied file's name contains multiple dots.
1066    panel.update_in(cx, |panel, window, cx| {
1067        panel.copy(&Default::default(), window, cx);
1068        panel.paste(&Default::default(), window, cx);
1069    });
1070    cx.executor().run_until_parked();
1071
1072    assert_eq!(
1073        visible_entries_as_strings(&panel, 0..50, cx),
1074        &[
1075            //
1076            "v root1",
1077            "      one.txt",
1078            "      [EDITOR: 'one copy.txt']  <== selected  <== marked",
1079            "      one.two.txt",
1080        ]
1081    );
1082
1083    panel.update_in(cx, |panel, window, cx| {
1084        panel.filename_editor.update(cx, |editor, cx| {
1085            let file_name_selections = editor.selections.all::<usize>(cx);
1086            assert_eq!(
1087                file_name_selections.len(),
1088                1,
1089                "File editing should have a single selection, but got: {file_name_selections:?}"
1090            );
1091            let file_name_selection = &file_name_selections[0];
1092            assert_eq!(
1093                file_name_selection.start,
1094                "one".len(),
1095                "Should select the file name disambiguation after the original file name"
1096            );
1097            assert_eq!(
1098                file_name_selection.end,
1099                "one copy".len(),
1100                "Should select the file name disambiguation until the extension"
1101            );
1102        });
1103        assert!(panel.confirm_edit(window, cx).is_none());
1104    });
1105
1106    panel.update_in(cx, |panel, window, cx| {
1107        panel.paste(&Default::default(), window, cx);
1108    });
1109    cx.executor().run_until_parked();
1110
1111    assert_eq!(
1112        visible_entries_as_strings(&panel, 0..50, cx),
1113        &[
1114            //
1115            "v root1",
1116            "      one.txt",
1117            "      one copy.txt",
1118            "      [EDITOR: 'one copy 1.txt']  <== selected  <== marked",
1119            "      one.two.txt",
1120        ]
1121    );
1122
1123    panel.update_in(cx, |panel, window, cx| {
1124        assert!(panel.confirm_edit(window, cx).is_none())
1125    });
1126}
1127
1128#[gpui::test]
1129async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1130    init_test(cx);
1131
1132    let fs = FakeFs::new(cx.executor().clone());
1133    fs.insert_tree(
1134        "/root1",
1135        json!({
1136            "one.txt": "",
1137            "two.txt": "",
1138            "three.txt": "",
1139            "a": {
1140                "0": { "q": "", "r": "", "s": "" },
1141                "1": { "t": "", "u": "" },
1142                "2": { "v": "", "w": "", "x": "", "y": "" },
1143            },
1144        }),
1145    )
1146    .await;
1147
1148    fs.insert_tree(
1149        "/root2",
1150        json!({
1151            "one.txt": "",
1152            "two.txt": "",
1153            "four.txt": "",
1154            "b": {
1155                "3": { "Q": "" },
1156                "4": { "R": "", "S": "", "T": "", "U": "" },
1157            },
1158        }),
1159    )
1160    .await;
1161
1162    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1163    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1164    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1165    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1166
1167    select_path(&panel, "root1/three.txt", cx);
1168    panel.update_in(cx, |panel, window, cx| {
1169        panel.cut(&Default::default(), window, cx);
1170    });
1171
1172    select_path(&panel, "root2/one.txt", cx);
1173    panel.update_in(cx, |panel, window, cx| {
1174        panel.select_next(&Default::default(), window, cx);
1175        panel.paste(&Default::default(), window, cx);
1176    });
1177    cx.executor().run_until_parked();
1178    assert_eq!(
1179        visible_entries_as_strings(&panel, 0..50, cx),
1180        &[
1181            //
1182            "v root1",
1183            "    > a",
1184            "      one.txt",
1185            "      two.txt",
1186            "v root2",
1187            "    > b",
1188            "      four.txt",
1189            "      one.txt",
1190            "      three.txt  <== selected  <== marked",
1191            "      two.txt",
1192        ]
1193    );
1194
1195    select_path(&panel, "root1/a", cx);
1196    panel.update_in(cx, |panel, window, cx| {
1197        panel.cut(&Default::default(), window, cx);
1198    });
1199    select_path(&panel, "root2/two.txt", cx);
1200    panel.update_in(cx, |panel, window, cx| {
1201        panel.select_next(&Default::default(), window, cx);
1202        panel.paste(&Default::default(), window, cx);
1203    });
1204
1205    cx.executor().run_until_parked();
1206    assert_eq!(
1207        visible_entries_as_strings(&panel, 0..50, cx),
1208        &[
1209            //
1210            "v root1",
1211            "      one.txt",
1212            "      two.txt",
1213            "v root2",
1214            "    > a  <== selected",
1215            "    > b",
1216            "      four.txt",
1217            "      one.txt",
1218            "      three.txt  <== marked",
1219            "      two.txt",
1220        ]
1221    );
1222}
1223
1224#[gpui::test]
1225async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1226    init_test(cx);
1227
1228    let fs = FakeFs::new(cx.executor().clone());
1229    fs.insert_tree(
1230        "/root1",
1231        json!({
1232            "one.txt": "",
1233            "two.txt": "",
1234            "three.txt": "",
1235            "a": {
1236                "0": { "q": "", "r": "", "s": "" },
1237                "1": { "t": "", "u": "" },
1238                "2": { "v": "", "w": "", "x": "", "y": "" },
1239            },
1240        }),
1241    )
1242    .await;
1243
1244    fs.insert_tree(
1245        "/root2",
1246        json!({
1247            "one.txt": "",
1248            "two.txt": "",
1249            "four.txt": "",
1250            "b": {
1251                "3": { "Q": "" },
1252                "4": { "R": "", "S": "", "T": "", "U": "" },
1253            },
1254        }),
1255    )
1256    .await;
1257
1258    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1259    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1260    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1261    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1262
1263    select_path(&panel, "root1/three.txt", cx);
1264    panel.update_in(cx, |panel, window, cx| {
1265        panel.copy(&Default::default(), window, cx);
1266    });
1267
1268    select_path(&panel, "root2/one.txt", cx);
1269    panel.update_in(cx, |panel, window, cx| {
1270        panel.select_next(&Default::default(), window, cx);
1271        panel.paste(&Default::default(), window, cx);
1272    });
1273    cx.executor().run_until_parked();
1274    assert_eq!(
1275        visible_entries_as_strings(&panel, 0..50, cx),
1276        &[
1277            //
1278            "v root1",
1279            "    > a",
1280            "      one.txt",
1281            "      three.txt",
1282            "      two.txt",
1283            "v root2",
1284            "    > b",
1285            "      four.txt",
1286            "      one.txt",
1287            "      three.txt  <== selected  <== marked",
1288            "      two.txt",
1289        ]
1290    );
1291
1292    select_path(&panel, "root1/three.txt", cx);
1293    panel.update_in(cx, |panel, window, cx| {
1294        panel.copy(&Default::default(), window, cx);
1295    });
1296    select_path(&panel, "root2/two.txt", cx);
1297    panel.update_in(cx, |panel, window, cx| {
1298        panel.select_next(&Default::default(), window, cx);
1299        panel.paste(&Default::default(), window, cx);
1300    });
1301
1302    cx.executor().run_until_parked();
1303    assert_eq!(
1304        visible_entries_as_strings(&panel, 0..50, cx),
1305        &[
1306            //
1307            "v root1",
1308            "    > a",
1309            "      one.txt",
1310            "      three.txt",
1311            "      two.txt",
1312            "v root2",
1313            "    > b",
1314            "      four.txt",
1315            "      one.txt",
1316            "      three.txt",
1317            "      [EDITOR: 'three copy.txt']  <== selected  <== marked",
1318            "      two.txt",
1319        ]
1320    );
1321
1322    panel.update_in(cx, |panel, window, cx| {
1323        panel.cancel(&menu::Cancel {}, window, cx)
1324    });
1325    cx.executor().run_until_parked();
1326
1327    select_path(&panel, "root1/a", cx);
1328    panel.update_in(cx, |panel, window, cx| {
1329        panel.copy(&Default::default(), window, cx);
1330    });
1331    select_path(&panel, "root2/two.txt", cx);
1332    panel.update_in(cx, |panel, window, cx| {
1333        panel.select_next(&Default::default(), window, cx);
1334        panel.paste(&Default::default(), window, cx);
1335    });
1336
1337    cx.executor().run_until_parked();
1338    assert_eq!(
1339        visible_entries_as_strings(&panel, 0..50, cx),
1340        &[
1341            //
1342            "v root1",
1343            "    > a",
1344            "      one.txt",
1345            "      three.txt",
1346            "      two.txt",
1347            "v root2",
1348            "    > a  <== selected",
1349            "    > b",
1350            "      four.txt",
1351            "      one.txt",
1352            "      three.txt",
1353            "      three copy.txt",
1354            "      two.txt",
1355        ]
1356    );
1357}
1358
1359#[gpui::test]
1360async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1361    init_test(cx);
1362
1363    let fs = FakeFs::new(cx.executor().clone());
1364    fs.insert_tree(
1365        "/root",
1366        json!({
1367            "a": {
1368                "one.txt": "",
1369                "two.txt": "",
1370                "inner_dir": {
1371                    "three.txt": "",
1372                    "four.txt": "",
1373                }
1374            },
1375            "b": {}
1376        }),
1377    )
1378    .await;
1379
1380    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1381    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1382    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1383    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1384
1385    select_path(&panel, "root/a", cx);
1386    panel.update_in(cx, |panel, window, cx| {
1387        panel.copy(&Default::default(), window, cx);
1388        panel.select_next(&Default::default(), window, cx);
1389        panel.paste(&Default::default(), window, cx);
1390    });
1391    cx.executor().run_until_parked();
1392
1393    let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1394    assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1395
1396    let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1397    assert_ne!(
1398        pasted_dir_file, None,
1399        "Pasted directory file should have an entry"
1400    );
1401
1402    let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1403    assert_ne!(
1404        pasted_dir_inner_dir, None,
1405        "Directories inside pasted directory should have an entry"
1406    );
1407
1408    toggle_expand_dir(&panel, "root/b/a", cx);
1409    toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1410
1411    assert_eq!(
1412        visible_entries_as_strings(&panel, 0..50, cx),
1413        &[
1414            //
1415            "v root",
1416            "    > a",
1417            "    v b",
1418            "        v a",
1419            "            v inner_dir  <== selected",
1420            "                  four.txt",
1421            "                  three.txt",
1422            "              one.txt",
1423            "              two.txt",
1424        ]
1425    );
1426
1427    select_path(&panel, "root", cx);
1428    panel.update_in(cx, |panel, window, cx| {
1429        panel.paste(&Default::default(), window, cx)
1430    });
1431    cx.executor().run_until_parked();
1432    assert_eq!(
1433        visible_entries_as_strings(&panel, 0..50, cx),
1434        &[
1435            //
1436            "v root",
1437            "    > a",
1438            "    > [EDITOR: 'a copy']  <== selected",
1439            "    v b",
1440            "        v a",
1441            "            v inner_dir",
1442            "                  four.txt",
1443            "                  three.txt",
1444            "              one.txt",
1445            "              two.txt"
1446        ]
1447    );
1448
1449    let confirm = panel.update_in(cx, |panel, window, cx| {
1450        panel
1451            .filename_editor
1452            .update(cx, |editor, cx| editor.set_text("c", window, cx));
1453        panel.confirm_edit(window, cx).unwrap()
1454    });
1455    assert_eq!(
1456        visible_entries_as_strings(&panel, 0..50, cx),
1457        &[
1458            //
1459            "v root",
1460            "    > a",
1461            "    > [PROCESSING: 'c']  <== selected",
1462            "    v b",
1463            "        v a",
1464            "            v inner_dir",
1465            "                  four.txt",
1466            "                  three.txt",
1467            "              one.txt",
1468            "              two.txt"
1469        ]
1470    );
1471
1472    confirm.await.unwrap();
1473
1474    panel.update_in(cx, |panel, window, cx| {
1475        panel.paste(&Default::default(), window, cx)
1476    });
1477    cx.executor().run_until_parked();
1478    assert_eq!(
1479        visible_entries_as_strings(&panel, 0..50, cx),
1480        &[
1481            //
1482            "v root",
1483            "    > a",
1484            "    v b",
1485            "        v a",
1486            "            v inner_dir",
1487            "                  four.txt",
1488            "                  three.txt",
1489            "              one.txt",
1490            "              two.txt",
1491            "    v c",
1492            "        > a  <== selected",
1493            "        > inner_dir",
1494            "          one.txt",
1495            "          two.txt",
1496        ]
1497    );
1498}
1499
1500#[gpui::test]
1501async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
1502    init_test(cx);
1503
1504    let fs = FakeFs::new(cx.executor().clone());
1505    fs.insert_tree(
1506        "/test",
1507        json!({
1508            "dir1": {
1509                "a.txt": "",
1510                "b.txt": "",
1511            },
1512            "dir2": {},
1513            "c.txt": "",
1514            "d.txt": "",
1515        }),
1516    )
1517    .await;
1518
1519    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1520    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1521    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1522    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1523
1524    toggle_expand_dir(&panel, "test/dir1", cx);
1525
1526    cx.simulate_modifiers_change(gpui::Modifiers {
1527        control: true,
1528        ..Default::default()
1529    });
1530
1531    select_path_with_mark(&panel, "test/dir1", cx);
1532    select_path_with_mark(&panel, "test/c.txt", cx);
1533
1534    assert_eq!(
1535        visible_entries_as_strings(&panel, 0..15, cx),
1536        &[
1537            "v test",
1538            "    v dir1  <== marked",
1539            "          a.txt",
1540            "          b.txt",
1541            "    > dir2",
1542            "      c.txt  <== selected  <== marked",
1543            "      d.txt",
1544        ],
1545        "Initial state before copying dir1 and c.txt"
1546    );
1547
1548    panel.update_in(cx, |panel, window, cx| {
1549        panel.copy(&Default::default(), window, cx);
1550    });
1551    select_path(&panel, "test/dir2", cx);
1552    panel.update_in(cx, |panel, window, cx| {
1553        panel.paste(&Default::default(), window, cx);
1554    });
1555    cx.executor().run_until_parked();
1556
1557    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1558
1559    assert_eq!(
1560        visible_entries_as_strings(&panel, 0..15, cx),
1561        &[
1562            "v test",
1563            "    v dir1  <== marked",
1564            "          a.txt",
1565            "          b.txt",
1566            "    v dir2",
1567            "        v dir1  <== selected",
1568            "              a.txt",
1569            "              b.txt",
1570            "          c.txt",
1571            "      c.txt  <== marked",
1572            "      d.txt",
1573        ],
1574        "Should copy dir1 as well as c.txt into dir2"
1575    );
1576
1577    // Disambiguating multiple files should not open the rename editor.
1578    select_path(&panel, "test/dir2", cx);
1579    panel.update_in(cx, |panel, window, cx| {
1580        panel.paste(&Default::default(), window, cx);
1581    });
1582    cx.executor().run_until_parked();
1583
1584    assert_eq!(
1585            visible_entries_as_strings(&panel, 0..15, cx),
1586            &[
1587                "v test",
1588                "    v dir1  <== marked",
1589                "          a.txt",
1590                "          b.txt",
1591                "    v dir2",
1592                "        v dir1",
1593                "              a.txt",
1594                "              b.txt",
1595                "        > dir1 copy  <== selected",
1596                "          c.txt",
1597                "          c copy.txt",
1598                "      c.txt  <== marked",
1599                "      d.txt",
1600            ],
1601            "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
1602        );
1603}
1604
1605#[gpui::test]
1606async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
1607    init_test(cx);
1608
1609    let fs = FakeFs::new(cx.executor().clone());
1610    fs.insert_tree(
1611        "/test",
1612        json!({
1613            "dir1": {
1614                "a.txt": "",
1615                "b.txt": "",
1616            },
1617            "dir2": {},
1618            "c.txt": "",
1619            "d.txt": "",
1620        }),
1621    )
1622    .await;
1623
1624    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1625    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1626    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1627    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1628
1629    toggle_expand_dir(&panel, "test/dir1", cx);
1630
1631    cx.simulate_modifiers_change(gpui::Modifiers {
1632        control: true,
1633        ..Default::default()
1634    });
1635
1636    select_path_with_mark(&panel, "test/dir1/a.txt", cx);
1637    select_path_with_mark(&panel, "test/dir1", cx);
1638    select_path_with_mark(&panel, "test/c.txt", cx);
1639
1640    assert_eq!(
1641        visible_entries_as_strings(&panel, 0..15, cx),
1642        &[
1643            "v test",
1644            "    v dir1  <== marked",
1645            "          a.txt  <== marked",
1646            "          b.txt",
1647            "    > dir2",
1648            "      c.txt  <== selected  <== marked",
1649            "      d.txt",
1650        ],
1651        "Initial state before copying a.txt, dir1 and c.txt"
1652    );
1653
1654    panel.update_in(cx, |panel, window, cx| {
1655        panel.copy(&Default::default(), window, cx);
1656    });
1657    select_path(&panel, "test/dir2", cx);
1658    panel.update_in(cx, |panel, window, cx| {
1659        panel.paste(&Default::default(), window, cx);
1660    });
1661    cx.executor().run_until_parked();
1662
1663    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1664
1665    assert_eq!(
1666        visible_entries_as_strings(&panel, 0..20, cx),
1667        &[
1668            "v test",
1669            "    v dir1  <== marked",
1670            "          a.txt  <== marked",
1671            "          b.txt",
1672            "    v dir2",
1673            "        v dir1  <== selected",
1674            "              a.txt",
1675            "              b.txt",
1676            "          c.txt",
1677            "      c.txt  <== marked",
1678            "      d.txt",
1679        ],
1680        "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
1681    );
1682}
1683
1684#[gpui::test]
1685async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1686    init_test_with_editor(cx);
1687
1688    let fs = FakeFs::new(cx.executor().clone());
1689    fs.insert_tree(
1690        path!("/src"),
1691        json!({
1692            "test": {
1693                "first.rs": "// First Rust file",
1694                "second.rs": "// Second Rust file",
1695                "third.rs": "// Third Rust file",
1696            }
1697        }),
1698    )
1699    .await;
1700
1701    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
1702    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1703    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1704    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1705
1706    toggle_expand_dir(&panel, "src/test", cx);
1707    select_path(&panel, "src/test/first.rs", cx);
1708    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1709    cx.executor().run_until_parked();
1710    assert_eq!(
1711        visible_entries_as_strings(&panel, 0..10, cx),
1712        &[
1713            "v src",
1714            "    v test",
1715            "          first.rs  <== selected  <== marked",
1716            "          second.rs",
1717            "          third.rs"
1718        ]
1719    );
1720    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
1721
1722    submit_deletion(&panel, cx);
1723    assert_eq!(
1724        visible_entries_as_strings(&panel, 0..10, cx),
1725        &[
1726            "v src",
1727            "    v test",
1728            "          second.rs  <== selected",
1729            "          third.rs"
1730        ],
1731        "Project panel should have no deleted file, no other file is selected in it"
1732    );
1733    ensure_no_open_items_and_panes(&workspace, cx);
1734
1735    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1736    cx.executor().run_until_parked();
1737    assert_eq!(
1738        visible_entries_as_strings(&panel, 0..10, cx),
1739        &[
1740            "v src",
1741            "    v test",
1742            "          second.rs  <== selected  <== marked",
1743            "          third.rs"
1744        ]
1745    );
1746    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
1747
1748    workspace
1749        .update(cx, |workspace, window, cx| {
1750            let active_items = workspace
1751                .panes()
1752                .iter()
1753                .filter_map(|pane| pane.read(cx).active_item())
1754                .collect::<Vec<_>>();
1755            assert_eq!(active_items.len(), 1);
1756            let open_editor = active_items
1757                .into_iter()
1758                .next()
1759                .unwrap()
1760                .downcast::<Editor>()
1761                .expect("Open item should be an editor");
1762            open_editor.update(cx, |editor, cx| {
1763                editor.set_text("Another text!", window, cx)
1764            });
1765        })
1766        .unwrap();
1767    submit_deletion_skipping_prompt(&panel, cx);
1768    assert_eq!(
1769        visible_entries_as_strings(&panel, 0..10, cx),
1770        &["v src", "    v test", "          third.rs  <== selected"],
1771        "Project panel should have no deleted file, with one last file remaining"
1772    );
1773    ensure_no_open_items_and_panes(&workspace, cx);
1774}
1775
1776#[gpui::test]
1777async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
1778    init_test_with_editor(cx);
1779
1780    let fs = FakeFs::new(cx.executor().clone());
1781    fs.insert_tree(
1782        "/src",
1783        json!({
1784            "test": {
1785                "first.rs": "// First Rust file",
1786                "second.rs": "// Second Rust file",
1787                "third.rs": "// Third Rust file",
1788            }
1789        }),
1790    )
1791    .await;
1792
1793    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
1794    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1795    let cx = &mut VisualTestContext::from_window(*workspace, cx);
1796    let panel = workspace
1797        .update(cx, |workspace, window, cx| {
1798            let panel = ProjectPanel::new(workspace, window, cx);
1799            workspace.add_panel(panel.clone(), window, cx);
1800            panel
1801        })
1802        .unwrap();
1803
1804    select_path(&panel, "src/", cx);
1805    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1806    cx.executor().run_until_parked();
1807    assert_eq!(
1808        visible_entries_as_strings(&panel, 0..10, cx),
1809        &[
1810            //
1811            "v src  <== selected",
1812            "    > test"
1813        ]
1814    );
1815    panel.update_in(cx, |panel, window, cx| {
1816        panel.new_directory(&NewDirectory, window, cx)
1817    });
1818    panel.update_in(cx, |panel, window, cx| {
1819        assert!(panel.filename_editor.read(cx).is_focused(window));
1820    });
1821    assert_eq!(
1822        visible_entries_as_strings(&panel, 0..10, cx),
1823        &[
1824            //
1825            "v src",
1826            "    > [EDITOR: '']  <== selected",
1827            "    > test"
1828        ]
1829    );
1830    panel.update_in(cx, |panel, window, cx| {
1831        panel
1832            .filename_editor
1833            .update(cx, |editor, cx| editor.set_text("test", window, cx));
1834        assert!(
1835            panel.confirm_edit(window, cx).is_none(),
1836            "Should not allow to confirm on conflicting new directory name"
1837        )
1838    });
1839    assert_eq!(
1840        visible_entries_as_strings(&panel, 0..10, cx),
1841        &[
1842            //
1843            "v src",
1844            "    > test"
1845        ],
1846        "File list should be unchanged after failed folder create confirmation"
1847    );
1848
1849    select_path(&panel, "src/test/", cx);
1850    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1851    cx.executor().run_until_parked();
1852    assert_eq!(
1853        visible_entries_as_strings(&panel, 0..10, cx),
1854        &[
1855            //
1856            "v src",
1857            "    > test  <== selected"
1858        ]
1859    );
1860    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1861    panel.update_in(cx, |panel, window, cx| {
1862        assert!(panel.filename_editor.read(cx).is_focused(window));
1863    });
1864    assert_eq!(
1865        visible_entries_as_strings(&panel, 0..10, cx),
1866        &[
1867            "v src",
1868            "    v test",
1869            "          [EDITOR: '']  <== selected",
1870            "          first.rs",
1871            "          second.rs",
1872            "          third.rs"
1873        ]
1874    );
1875    panel.update_in(cx, |panel, window, cx| {
1876        panel
1877            .filename_editor
1878            .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
1879        assert!(
1880            panel.confirm_edit(window, cx).is_none(),
1881            "Should not allow to confirm on conflicting new file name"
1882        )
1883    });
1884    assert_eq!(
1885        visible_entries_as_strings(&panel, 0..10, cx),
1886        &[
1887            "v src",
1888            "    v test",
1889            "          first.rs",
1890            "          second.rs",
1891            "          third.rs"
1892        ],
1893        "File list should be unchanged after failed file create confirmation"
1894    );
1895
1896    select_path(&panel, "src/test/first.rs", cx);
1897    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1898    cx.executor().run_until_parked();
1899    assert_eq!(
1900        visible_entries_as_strings(&panel, 0..10, cx),
1901        &[
1902            "v src",
1903            "    v test",
1904            "          first.rs  <== selected",
1905            "          second.rs",
1906            "          third.rs"
1907        ],
1908    );
1909    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
1910    panel.update_in(cx, |panel, window, cx| {
1911        assert!(panel.filename_editor.read(cx).is_focused(window));
1912    });
1913    assert_eq!(
1914        visible_entries_as_strings(&panel, 0..10, cx),
1915        &[
1916            "v src",
1917            "    v test",
1918            "          [EDITOR: 'first.rs']  <== selected",
1919            "          second.rs",
1920            "          third.rs"
1921        ]
1922    );
1923    panel.update_in(cx, |panel, window, cx| {
1924        panel
1925            .filename_editor
1926            .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
1927        assert!(
1928            panel.confirm_edit(window, cx).is_none(),
1929            "Should not allow to confirm on conflicting file rename"
1930        )
1931    });
1932    assert_eq!(
1933        visible_entries_as_strings(&panel, 0..10, cx),
1934        &[
1935            "v src",
1936            "    v test",
1937            "          first.rs  <== selected",
1938            "          second.rs",
1939            "          third.rs"
1940        ],
1941        "File list should be unchanged after failed rename confirmation"
1942    );
1943}
1944
1945#[gpui::test]
1946async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
1947    init_test_with_editor(cx);
1948
1949    let fs = FakeFs::new(cx.executor().clone());
1950    fs.insert_tree(
1951        path!("/root"),
1952        json!({
1953            "tree1": {
1954                ".git": {},
1955                "dir1": {
1956                    "modified1.txt": "1",
1957                    "unmodified1.txt": "1",
1958                    "modified2.txt": "1",
1959                },
1960                "dir2": {
1961                    "modified3.txt": "1",
1962                    "unmodified2.txt": "1",
1963                },
1964                "modified4.txt": "1",
1965                "unmodified3.txt": "1",
1966            },
1967            "tree2": {
1968                ".git": {},
1969                "dir3": {
1970                    "modified5.txt": "1",
1971                    "unmodified4.txt": "1",
1972                },
1973                "modified6.txt": "1",
1974                "unmodified5.txt": "1",
1975            }
1976        }),
1977    )
1978    .await;
1979
1980    // Mark files as git modified
1981    fs.set_git_content_for_repo(
1982        path!("/root/tree1/.git").as_ref(),
1983        &[
1984            ("dir1/modified1.txt".into(), "modified".into(), None),
1985            ("dir1/modified2.txt".into(), "modified".into(), None),
1986            ("modified4.txt".into(), "modified".into(), None),
1987            ("dir2/modified3.txt".into(), "modified".into(), None),
1988        ],
1989    );
1990    fs.set_git_content_for_repo(
1991        path!("/root/tree2/.git").as_ref(),
1992        &[
1993            ("dir3/modified5.txt".into(), "modified".into(), None),
1994            ("modified6.txt".into(), "modified".into(), None),
1995        ],
1996    );
1997
1998    let project = Project::test(
1999        fs.clone(),
2000        [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2001        cx,
2002    )
2003    .await;
2004    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2005    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2006    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2007
2008    // Check initial state
2009    assert_eq!(
2010        visible_entries_as_strings(&panel, 0..15, cx),
2011        &[
2012            "v tree1",
2013            "    > .git",
2014            "    > dir1",
2015            "    > dir2",
2016            "      modified4.txt",
2017            "      unmodified3.txt",
2018            "v tree2",
2019            "    > .git",
2020            "    > dir3",
2021            "      modified6.txt",
2022            "      unmodified5.txt"
2023        ],
2024    );
2025
2026    // Test selecting next modified entry
2027    panel.update_in(cx, |panel, window, cx| {
2028        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2029    });
2030
2031    assert_eq!(
2032        visible_entries_as_strings(&panel, 0..6, cx),
2033        &[
2034            "v tree1",
2035            "    > .git",
2036            "    v dir1",
2037            "          modified1.txt  <== selected",
2038            "          modified2.txt",
2039            "          unmodified1.txt",
2040        ],
2041    );
2042
2043    panel.update_in(cx, |panel, window, cx| {
2044        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2045    });
2046
2047    assert_eq!(
2048        visible_entries_as_strings(&panel, 0..6, cx),
2049        &[
2050            "v tree1",
2051            "    > .git",
2052            "    v dir1",
2053            "          modified1.txt",
2054            "          modified2.txt  <== selected",
2055            "          unmodified1.txt",
2056        ],
2057    );
2058
2059    panel.update_in(cx, |panel, window, cx| {
2060        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2061    });
2062
2063    assert_eq!(
2064        visible_entries_as_strings(&panel, 6..9, cx),
2065        &[
2066            "    v dir2",
2067            "          modified3.txt  <== selected",
2068            "          unmodified2.txt",
2069        ],
2070    );
2071
2072    panel.update_in(cx, |panel, window, cx| {
2073        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2074    });
2075
2076    assert_eq!(
2077        visible_entries_as_strings(&panel, 9..11, cx),
2078        &["      modified4.txt  <== selected", "      unmodified3.txt",],
2079    );
2080
2081    panel.update_in(cx, |panel, window, cx| {
2082        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2083    });
2084
2085    assert_eq!(
2086        visible_entries_as_strings(&panel, 13..16, cx),
2087        &[
2088            "    v dir3",
2089            "          modified5.txt  <== selected",
2090            "          unmodified4.txt",
2091        ],
2092    );
2093
2094    panel.update_in(cx, |panel, window, cx| {
2095        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2096    });
2097
2098    assert_eq!(
2099        visible_entries_as_strings(&panel, 16..18, cx),
2100        &["      modified6.txt  <== selected", "      unmodified5.txt",],
2101    );
2102
2103    // Wraps around to first modified file
2104    panel.update_in(cx, |panel, window, cx| {
2105        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2106    });
2107
2108    assert_eq!(
2109        visible_entries_as_strings(&panel, 0..18, cx),
2110        &[
2111            "v tree1",
2112            "    > .git",
2113            "    v dir1",
2114            "          modified1.txt  <== selected",
2115            "          modified2.txt",
2116            "          unmodified1.txt",
2117            "    v dir2",
2118            "          modified3.txt",
2119            "          unmodified2.txt",
2120            "      modified4.txt",
2121            "      unmodified3.txt",
2122            "v tree2",
2123            "    > .git",
2124            "    v dir3",
2125            "          modified5.txt",
2126            "          unmodified4.txt",
2127            "      modified6.txt",
2128            "      unmodified5.txt",
2129        ],
2130    );
2131
2132    // Wraps around again to last modified file
2133    panel.update_in(cx, |panel, window, cx| {
2134        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2135    });
2136
2137    assert_eq!(
2138        visible_entries_as_strings(&panel, 16..18, cx),
2139        &["      modified6.txt  <== selected", "      unmodified5.txt",],
2140    );
2141
2142    panel.update_in(cx, |panel, window, cx| {
2143        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2144    });
2145
2146    assert_eq!(
2147        visible_entries_as_strings(&panel, 13..16, cx),
2148        &[
2149            "    v dir3",
2150            "          modified5.txt  <== selected",
2151            "          unmodified4.txt",
2152        ],
2153    );
2154
2155    panel.update_in(cx, |panel, window, cx| {
2156        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2157    });
2158
2159    assert_eq!(
2160        visible_entries_as_strings(&panel, 9..11, cx),
2161        &["      modified4.txt  <== selected", "      unmodified3.txt",],
2162    );
2163
2164    panel.update_in(cx, |panel, window, cx| {
2165        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2166    });
2167
2168    assert_eq!(
2169        visible_entries_as_strings(&panel, 6..9, cx),
2170        &[
2171            "    v dir2",
2172            "          modified3.txt  <== selected",
2173            "          unmodified2.txt",
2174        ],
2175    );
2176
2177    panel.update_in(cx, |panel, window, cx| {
2178        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2179    });
2180
2181    assert_eq!(
2182        visible_entries_as_strings(&panel, 0..6, cx),
2183        &[
2184            "v tree1",
2185            "    > .git",
2186            "    v dir1",
2187            "          modified1.txt",
2188            "          modified2.txt  <== selected",
2189            "          unmodified1.txt",
2190        ],
2191    );
2192
2193    panel.update_in(cx, |panel, window, cx| {
2194        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2195    });
2196
2197    assert_eq!(
2198        visible_entries_as_strings(&panel, 0..6, cx),
2199        &[
2200            "v tree1",
2201            "    > .git",
2202            "    v dir1",
2203            "          modified1.txt  <== selected",
2204            "          modified2.txt",
2205            "          unmodified1.txt",
2206        ],
2207    );
2208}
2209
2210#[gpui::test]
2211async fn test_select_directory(cx: &mut gpui::TestAppContext) {
2212    init_test_with_editor(cx);
2213
2214    let fs = FakeFs::new(cx.executor().clone());
2215    fs.insert_tree(
2216        "/project_root",
2217        json!({
2218            "dir_1": {
2219                "nested_dir": {
2220                    "file_a.py": "# File contents",
2221                }
2222            },
2223            "file_1.py": "# File contents",
2224            "dir_2": {
2225
2226            },
2227            "dir_3": {
2228
2229            },
2230            "file_2.py": "# File contents",
2231            "dir_4": {
2232
2233            },
2234        }),
2235    )
2236    .await;
2237
2238    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2239    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2240    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2241    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2242
2243    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2244    cx.executor().run_until_parked();
2245    select_path(&panel, "project_root/dir_1", cx);
2246    cx.executor().run_until_parked();
2247    assert_eq!(
2248        visible_entries_as_strings(&panel, 0..10, cx),
2249        &[
2250            "v project_root",
2251            "    > dir_1  <== selected",
2252            "    > dir_2",
2253            "    > dir_3",
2254            "    > dir_4",
2255            "      file_1.py",
2256            "      file_2.py",
2257        ]
2258    );
2259    panel.update_in(cx, |panel, window, cx| {
2260        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2261    });
2262
2263    assert_eq!(
2264        visible_entries_as_strings(&panel, 0..10, cx),
2265        &[
2266            "v project_root  <== selected",
2267            "    > dir_1",
2268            "    > dir_2",
2269            "    > dir_3",
2270            "    > dir_4",
2271            "      file_1.py",
2272            "      file_2.py",
2273        ]
2274    );
2275
2276    panel.update_in(cx, |panel, window, cx| {
2277        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2278    });
2279
2280    assert_eq!(
2281        visible_entries_as_strings(&panel, 0..10, cx),
2282        &[
2283            "v project_root",
2284            "    > dir_1",
2285            "    > dir_2",
2286            "    > dir_3",
2287            "    > dir_4  <== selected",
2288            "      file_1.py",
2289            "      file_2.py",
2290        ]
2291    );
2292
2293    panel.update_in(cx, |panel, window, cx| {
2294        panel.select_next_directory(&SelectNextDirectory, window, cx)
2295    });
2296
2297    assert_eq!(
2298        visible_entries_as_strings(&panel, 0..10, cx),
2299        &[
2300            "v project_root  <== selected",
2301            "    > dir_1",
2302            "    > dir_2",
2303            "    > dir_3",
2304            "    > dir_4",
2305            "      file_1.py",
2306            "      file_2.py",
2307        ]
2308    );
2309}
2310#[gpui::test]
2311async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
2312    init_test_with_editor(cx);
2313
2314    let fs = FakeFs::new(cx.executor().clone());
2315    fs.insert_tree(
2316        "/project_root",
2317        json!({
2318            "dir_1": {
2319                "nested_dir": {
2320                    "file_a.py": "# File contents",
2321                }
2322            },
2323            "file_1.py": "# File contents",
2324            "file_2.py": "# File contents",
2325            "zdir_2": {
2326                "nested_dir2": {
2327                    "file_b.py": "# File contents",
2328                }
2329            },
2330        }),
2331    )
2332    .await;
2333
2334    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2335    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2336    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2337    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2338
2339    assert_eq!(
2340        visible_entries_as_strings(&panel, 0..10, cx),
2341        &[
2342            "v project_root",
2343            "    > dir_1",
2344            "    > zdir_2",
2345            "      file_1.py",
2346            "      file_2.py",
2347        ]
2348    );
2349    panel.update_in(cx, |panel, window, cx| {
2350        panel.select_first(&SelectFirst, window, cx)
2351    });
2352
2353    assert_eq!(
2354        visible_entries_as_strings(&panel, 0..10, cx),
2355        &[
2356            "v project_root  <== selected",
2357            "    > dir_1",
2358            "    > zdir_2",
2359            "      file_1.py",
2360            "      file_2.py",
2361        ]
2362    );
2363
2364    panel.update_in(cx, |panel, window, cx| {
2365        panel.select_last(&SelectLast, window, cx)
2366    });
2367
2368    assert_eq!(
2369        visible_entries_as_strings(&panel, 0..10, cx),
2370        &[
2371            "v project_root",
2372            "    > dir_1",
2373            "    > zdir_2",
2374            "      file_1.py",
2375            "      file_2.py  <== selected",
2376        ]
2377    );
2378}
2379
2380#[gpui::test]
2381async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2382    init_test_with_editor(cx);
2383
2384    let fs = FakeFs::new(cx.executor().clone());
2385    fs.insert_tree(
2386        "/project_root",
2387        json!({
2388            "dir_1": {
2389                "nested_dir": {
2390                    "file_a.py": "# File contents",
2391                }
2392            },
2393            "file_1.py": "# File contents",
2394        }),
2395    )
2396    .await;
2397
2398    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2399    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2400    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2401    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2402
2403    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2404    cx.executor().run_until_parked();
2405    select_path(&panel, "project_root/dir_1", cx);
2406    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2407    select_path(&panel, "project_root/dir_1/nested_dir", cx);
2408    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2409    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2410    cx.executor().run_until_parked();
2411    assert_eq!(
2412        visible_entries_as_strings(&panel, 0..10, cx),
2413        &[
2414            "v project_root",
2415            "    v dir_1",
2416            "        > nested_dir  <== selected",
2417            "      file_1.py",
2418        ]
2419    );
2420}
2421
2422#[gpui::test]
2423async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2424    init_test_with_editor(cx);
2425
2426    let fs = FakeFs::new(cx.executor().clone());
2427    fs.insert_tree(
2428        "/project_root",
2429        json!({
2430            "dir_1": {
2431                "nested_dir": {
2432                    "file_a.py": "# File contents",
2433                    "file_b.py": "# File contents",
2434                    "file_c.py": "# File contents",
2435                },
2436                "file_1.py": "# File contents",
2437                "file_2.py": "# File contents",
2438                "file_3.py": "# File contents",
2439            },
2440            "dir_2": {
2441                "file_1.py": "# File contents",
2442                "file_2.py": "# File contents",
2443                "file_3.py": "# File contents",
2444            }
2445        }),
2446    )
2447    .await;
2448
2449    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2450    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2451    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2452    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2453
2454    panel.update_in(cx, |panel, window, cx| {
2455        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2456    });
2457    cx.executor().run_until_parked();
2458    assert_eq!(
2459        visible_entries_as_strings(&panel, 0..10, cx),
2460        &["v project_root", "    > dir_1", "    > dir_2",]
2461    );
2462
2463    // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2464    toggle_expand_dir(&panel, "project_root/dir_1", cx);
2465    cx.executor().run_until_parked();
2466    assert_eq!(
2467        visible_entries_as_strings(&panel, 0..10, cx),
2468        &[
2469            "v project_root",
2470            "    v dir_1  <== selected",
2471            "        > nested_dir",
2472            "          file_1.py",
2473            "          file_2.py",
2474            "          file_3.py",
2475            "    > dir_2",
2476        ]
2477    );
2478}
2479
2480#[gpui::test]
2481async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2482    init_test(cx);
2483
2484    let fs = FakeFs::new(cx.executor().clone());
2485    fs.as_fake().insert_tree(path!("/root"), json!({})).await;
2486    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
2487    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2488    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2489    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2490
2491    // Make a new buffer with no backing file
2492    workspace
2493        .update(cx, |workspace, window, cx| {
2494            Editor::new_file(workspace, &Default::default(), window, cx)
2495        })
2496        .unwrap();
2497
2498    cx.executor().run_until_parked();
2499
2500    // "Save as" the buffer, creating a new backing file for it
2501    let save_task = workspace
2502        .update(cx, |workspace, window, cx| {
2503            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2504        })
2505        .unwrap();
2506
2507    cx.executor().run_until_parked();
2508    cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
2509    save_task.await.unwrap();
2510
2511    // Rename the file
2512    select_path(&panel, "root/new", cx);
2513    assert_eq!(
2514        visible_entries_as_strings(&panel, 0..10, cx),
2515        &["v root", "      new  <== selected  <== marked"]
2516    );
2517    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2518    panel.update_in(cx, |panel, window, cx| {
2519        panel
2520            .filename_editor
2521            .update(cx, |editor, cx| editor.set_text("newer", window, cx));
2522    });
2523    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2524
2525    cx.executor().run_until_parked();
2526    assert_eq!(
2527        visible_entries_as_strings(&panel, 0..10, cx),
2528        &["v root", "      newer  <== selected"]
2529    );
2530
2531    workspace
2532        .update(cx, |workspace, window, cx| {
2533            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2534        })
2535        .unwrap()
2536        .await
2537        .unwrap();
2538
2539    cx.executor().run_until_parked();
2540    // assert that saving the file doesn't restore "new"
2541    assert_eq!(
2542        visible_entries_as_strings(&panel, 0..10, cx),
2543        &["v root", "      newer  <== selected"]
2544    );
2545}
2546
2547#[gpui::test]
2548#[cfg_attr(target_os = "windows", ignore)]
2549async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2550    init_test_with_editor(cx);
2551
2552    let fs = FakeFs::new(cx.executor().clone());
2553    fs.insert_tree(
2554        "/root1",
2555        json!({
2556            "dir1": {
2557                "file1.txt": "content 1",
2558            },
2559        }),
2560    )
2561    .await;
2562
2563    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2564    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2565    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2566    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2567
2568    toggle_expand_dir(&panel, "root1/dir1", cx);
2569
2570    assert_eq!(
2571        visible_entries_as_strings(&panel, 0..20, cx),
2572        &["v root1", "    v dir1  <== selected", "          file1.txt",],
2573        "Initial state with worktrees"
2574    );
2575
2576    select_path(&panel, "root1", cx);
2577    assert_eq!(
2578        visible_entries_as_strings(&panel, 0..20, cx),
2579        &["v root1  <== selected", "    v dir1", "          file1.txt",],
2580    );
2581
2582    // Rename root1 to new_root1
2583    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2584
2585    assert_eq!(
2586        visible_entries_as_strings(&panel, 0..20, cx),
2587        &[
2588            "v [EDITOR: 'root1']  <== selected",
2589            "    v dir1",
2590            "          file1.txt",
2591        ],
2592    );
2593
2594    let confirm = panel.update_in(cx, |panel, window, cx| {
2595        panel
2596            .filename_editor
2597            .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
2598        panel.confirm_edit(window, cx).unwrap()
2599    });
2600    confirm.await.unwrap();
2601    assert_eq!(
2602        visible_entries_as_strings(&panel, 0..20, cx),
2603        &[
2604            "v new_root1  <== selected",
2605            "    v dir1",
2606            "          file1.txt",
2607        ],
2608        "Should update worktree name"
2609    );
2610
2611    // Ensure internal paths have been updated
2612    select_path(&panel, "new_root1/dir1/file1.txt", cx);
2613    assert_eq!(
2614        visible_entries_as_strings(&panel, 0..20, cx),
2615        &[
2616            "v new_root1",
2617            "    v dir1",
2618            "          file1.txt  <== selected",
2619        ],
2620        "Files in renamed worktree are selectable"
2621    );
2622}
2623
2624#[gpui::test]
2625async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
2626    init_test_with_editor(cx);
2627    let fs = FakeFs::new(cx.executor().clone());
2628    fs.insert_tree(
2629        "/project_root",
2630        json!({
2631            "dir_1": {
2632                "nested_dir": {
2633                    "file_a.py": "# File contents",
2634                }
2635            },
2636            "file_1.py": "# File contents",
2637        }),
2638    )
2639    .await;
2640
2641    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2642    let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
2643    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2644    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2645    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2646    cx.update(|window, cx| {
2647        panel.update(cx, |this, cx| {
2648            this.select_next(&Default::default(), window, cx);
2649            this.expand_selected_entry(&Default::default(), window, cx);
2650            this.expand_selected_entry(&Default::default(), window, cx);
2651            this.select_next(&Default::default(), window, cx);
2652            this.expand_selected_entry(&Default::default(), window, cx);
2653            this.select_next(&Default::default(), window, cx);
2654        })
2655    });
2656    assert_eq!(
2657        visible_entries_as_strings(&panel, 0..10, cx),
2658        &[
2659            "v project_root",
2660            "    v dir_1",
2661            "        v nested_dir",
2662            "              file_a.py  <== selected",
2663            "      file_1.py",
2664        ]
2665    );
2666    let modifiers_with_shift = gpui::Modifiers {
2667        shift: true,
2668        ..Default::default()
2669    };
2670    cx.simulate_modifiers_change(modifiers_with_shift);
2671    cx.update(|window, cx| {
2672        panel.update(cx, |this, cx| {
2673            this.select_next(&Default::default(), window, cx);
2674        })
2675    });
2676    assert_eq!(
2677        visible_entries_as_strings(&panel, 0..10, cx),
2678        &[
2679            "v project_root",
2680            "    v dir_1",
2681            "        v nested_dir",
2682            "              file_a.py",
2683            "      file_1.py  <== selected  <== marked",
2684        ]
2685    );
2686    cx.update(|window, cx| {
2687        panel.update(cx, |this, cx| {
2688            this.select_previous(&Default::default(), window, cx);
2689        })
2690    });
2691    assert_eq!(
2692        visible_entries_as_strings(&panel, 0..10, cx),
2693        &[
2694            "v project_root",
2695            "    v dir_1",
2696            "        v nested_dir",
2697            "              file_a.py  <== selected  <== marked",
2698            "      file_1.py  <== marked",
2699        ]
2700    );
2701    cx.update(|window, cx| {
2702        panel.update(cx, |this, cx| {
2703            let drag = DraggedSelection {
2704                active_selection: this.selection.unwrap(),
2705                marked_selections: Arc::new(this.marked_entries.clone()),
2706            };
2707            let target_entry = this
2708                .project
2709                .read(cx)
2710                .entry_for_path(&(worktree_id, "").into(), cx)
2711                .unwrap();
2712            this.drag_onto(&drag, target_entry.id, false, window, cx);
2713        });
2714    });
2715    cx.run_until_parked();
2716    assert_eq!(
2717        visible_entries_as_strings(&panel, 0..10, cx),
2718        &[
2719            "v project_root",
2720            "    v dir_1",
2721            "        v nested_dir",
2722            "      file_1.py  <== marked",
2723            "      file_a.py  <== selected  <== marked",
2724        ]
2725    );
2726    // ESC clears out all marks
2727    cx.update(|window, cx| {
2728        panel.update(cx, |this, cx| {
2729            this.cancel(&menu::Cancel, window, cx);
2730        })
2731    });
2732    assert_eq!(
2733        visible_entries_as_strings(&panel, 0..10, cx),
2734        &[
2735            "v project_root",
2736            "    v dir_1",
2737            "        v nested_dir",
2738            "      file_1.py",
2739            "      file_a.py  <== selected",
2740        ]
2741    );
2742    // ESC clears out all marks
2743    cx.update(|window, cx| {
2744        panel.update(cx, |this, cx| {
2745            this.select_previous(&SelectPrevious, window, cx);
2746            this.select_next(&SelectNext, window, cx);
2747        })
2748    });
2749    assert_eq!(
2750        visible_entries_as_strings(&panel, 0..10, cx),
2751        &[
2752            "v project_root",
2753            "    v dir_1",
2754            "        v nested_dir",
2755            "      file_1.py  <== marked",
2756            "      file_a.py  <== selected  <== marked",
2757        ]
2758    );
2759    cx.simulate_modifiers_change(Default::default());
2760    cx.update(|window, cx| {
2761        panel.update(cx, |this, cx| {
2762            this.cut(&Cut, window, cx);
2763            this.select_previous(&SelectPrevious, window, cx);
2764            this.select_previous(&SelectPrevious, window, cx);
2765
2766            this.paste(&Paste, window, cx);
2767            // this.expand_selected_entry(&ExpandSelectedEntry, cx);
2768        })
2769    });
2770    cx.run_until_parked();
2771    assert_eq!(
2772        visible_entries_as_strings(&panel, 0..10, cx),
2773        &[
2774            "v project_root",
2775            "    v dir_1",
2776            "        v nested_dir",
2777            "              file_1.py  <== marked",
2778            "              file_a.py  <== selected  <== marked",
2779        ]
2780    );
2781    cx.simulate_modifiers_change(modifiers_with_shift);
2782    cx.update(|window, cx| {
2783        panel.update(cx, |this, cx| {
2784            this.expand_selected_entry(&Default::default(), window, cx);
2785            this.select_next(&SelectNext, window, cx);
2786            this.select_next(&SelectNext, window, cx);
2787        })
2788    });
2789    submit_deletion(&panel, cx);
2790    assert_eq!(
2791        visible_entries_as_strings(&panel, 0..10, cx),
2792        &[
2793            "v project_root",
2794            "    v dir_1",
2795            "        v nested_dir  <== selected",
2796        ]
2797    );
2798}
2799#[gpui::test]
2800async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2801    init_test_with_editor(cx);
2802    cx.update(|cx| {
2803        cx.update_global::<SettingsStore, _>(|store, cx| {
2804            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2805                worktree_settings.file_scan_exclusions = Some(Vec::new());
2806            });
2807            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2808                project_panel_settings.auto_reveal_entries = Some(false)
2809            });
2810        })
2811    });
2812
2813    let fs = FakeFs::new(cx.background_executor.clone());
2814    fs.insert_tree(
2815        "/project_root",
2816        json!({
2817            ".git": {},
2818            ".gitignore": "**/gitignored_dir",
2819            "dir_1": {
2820                "file_1.py": "# File 1_1 contents",
2821                "file_2.py": "# File 1_2 contents",
2822                "file_3.py": "# File 1_3 contents",
2823                "gitignored_dir": {
2824                    "file_a.py": "# File contents",
2825                    "file_b.py": "# File contents",
2826                    "file_c.py": "# File contents",
2827                },
2828            },
2829            "dir_2": {
2830                "file_1.py": "# File 2_1 contents",
2831                "file_2.py": "# File 2_2 contents",
2832                "file_3.py": "# File 2_3 contents",
2833            }
2834        }),
2835    )
2836    .await;
2837
2838    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2839    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2840    let cx = &mut VisualTestContext::from_window(*workspace, cx);
2841    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2842
2843    assert_eq!(
2844        visible_entries_as_strings(&panel, 0..20, cx),
2845        &[
2846            "v project_root",
2847            "    > .git",
2848            "    > dir_1",
2849            "    > dir_2",
2850            "      .gitignore",
2851        ]
2852    );
2853
2854    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2855        .expect("dir 1 file is not ignored and should have an entry");
2856    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2857        .expect("dir 2 file is not ignored and should have an entry");
2858    let gitignored_dir_file =
2859        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2860    assert_eq!(
2861        gitignored_dir_file, None,
2862        "File in the gitignored dir should not have an entry before its dir is toggled"
2863    );
2864
2865    toggle_expand_dir(&panel, "project_root/dir_1", cx);
2866    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2867    cx.executor().run_until_parked();
2868    assert_eq!(
2869        visible_entries_as_strings(&panel, 0..20, cx),
2870        &[
2871            "v project_root",
2872            "    > .git",
2873            "    v dir_1",
2874            "        v gitignored_dir  <== selected",
2875            "              file_a.py",
2876            "              file_b.py",
2877            "              file_c.py",
2878            "          file_1.py",
2879            "          file_2.py",
2880            "          file_3.py",
2881            "    > dir_2",
2882            "      .gitignore",
2883        ],
2884        "Should show gitignored dir file list in the project panel"
2885    );
2886    let gitignored_dir_file =
2887        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2888            .expect("after gitignored dir got opened, a file entry should be present");
2889
2890    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2891    toggle_expand_dir(&panel, "project_root/dir_1", cx);
2892    assert_eq!(
2893        visible_entries_as_strings(&panel, 0..20, cx),
2894        &[
2895            "v project_root",
2896            "    > .git",
2897            "    > dir_1  <== selected",
2898            "    > dir_2",
2899            "      .gitignore",
2900        ],
2901        "Should hide all dir contents again and prepare for the auto reveal test"
2902    );
2903
2904    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
2905        panel.update(cx, |panel, cx| {
2906            panel.project.update(cx, |_, cx| {
2907                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
2908            })
2909        });
2910        cx.run_until_parked();
2911        assert_eq!(
2912                visible_entries_as_strings(&panel, 0..20, cx),
2913                &[
2914                    "v project_root",
2915                    "    > .git",
2916                    "    > dir_1  <== selected",
2917                    "    > dir_2",
2918                    "      .gitignore",
2919                ],
2920                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
2921            );
2922    }
2923
2924    cx.update(|_, cx| {
2925        cx.update_global::<SettingsStore, _>(|store, cx| {
2926            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2927                project_panel_settings.auto_reveal_entries = Some(true)
2928            });
2929        })
2930    });
2931
2932    panel.update(cx, |panel, cx| {
2933        panel.project.update(cx, |_, cx| {
2934            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
2935        })
2936    });
2937    cx.run_until_parked();
2938    assert_eq!(
2939        visible_entries_as_strings(&panel, 0..20, cx),
2940        &[
2941            "v project_root",
2942            "    > .git",
2943            "    v dir_1",
2944            "        > gitignored_dir",
2945            "          file_1.py  <== selected  <== marked",
2946            "          file_2.py",
2947            "          file_3.py",
2948            "    > dir_2",
2949            "      .gitignore",
2950        ],
2951        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
2952    );
2953
2954    panel.update(cx, |panel, cx| {
2955        panel.project.update(cx, |_, cx| {
2956            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
2957        })
2958    });
2959    cx.run_until_parked();
2960    assert_eq!(
2961        visible_entries_as_strings(&panel, 0..20, cx),
2962        &[
2963            "v project_root",
2964            "    > .git",
2965            "    v dir_1",
2966            "        > gitignored_dir",
2967            "          file_1.py",
2968            "          file_2.py",
2969            "          file_3.py",
2970            "    v dir_2",
2971            "          file_1.py  <== selected  <== marked",
2972            "          file_2.py",
2973            "          file_3.py",
2974            "      .gitignore",
2975        ],
2976        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
2977    );
2978
2979    panel.update(cx, |panel, cx| {
2980        panel.project.update(cx, |_, cx| {
2981            cx.emit(project::Event::ActiveEntryChanged(Some(
2982                gitignored_dir_file,
2983            )))
2984        })
2985    });
2986    cx.run_until_parked();
2987    assert_eq!(
2988            visible_entries_as_strings(&panel, 0..20, cx),
2989            &[
2990                "v project_root",
2991                "    > .git",
2992                "    v dir_1",
2993                "        > gitignored_dir",
2994                "          file_1.py",
2995                "          file_2.py",
2996                "          file_3.py",
2997                "    v dir_2",
2998                "          file_1.py  <== selected  <== marked",
2999                "          file_2.py",
3000                "          file_3.py",
3001                "      .gitignore",
3002            ],
3003            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3004        );
3005
3006    panel.update(cx, |panel, cx| {
3007        panel.project.update(cx, |_, cx| {
3008            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3009        })
3010    });
3011    cx.run_until_parked();
3012    assert_eq!(
3013        visible_entries_as_strings(&panel, 0..20, cx),
3014        &[
3015            "v project_root",
3016            "    > .git",
3017            "    v dir_1",
3018            "        v gitignored_dir",
3019            "              file_a.py  <== selected  <== marked",
3020            "              file_b.py",
3021            "              file_c.py",
3022            "          file_1.py",
3023            "          file_2.py",
3024            "          file_3.py",
3025            "    v dir_2",
3026            "          file_1.py",
3027            "          file_2.py",
3028            "          file_3.py",
3029            "      .gitignore",
3030        ],
3031        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3032    );
3033}
3034
3035#[gpui::test]
3036async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3037    init_test_with_editor(cx);
3038    cx.update(|cx| {
3039        cx.update_global::<SettingsStore, _>(|store, cx| {
3040            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3041                worktree_settings.file_scan_exclusions = Some(Vec::new());
3042                worktree_settings.file_scan_inclusions =
3043                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3044            });
3045            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3046                project_panel_settings.auto_reveal_entries = Some(false)
3047            });
3048        })
3049    });
3050
3051    let fs = FakeFs::new(cx.background_executor.clone());
3052    fs.insert_tree(
3053        "/project_root",
3054        json!({
3055            ".git": {},
3056            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3057            "dir_1": {
3058                "file_1.py": "# File 1_1 contents",
3059                "file_2.py": "# File 1_2 contents",
3060                "file_3.py": "# File 1_3 contents",
3061                "gitignored_dir": {
3062                    "file_a.py": "# File contents",
3063                    "file_b.py": "# File contents",
3064                    "file_c.py": "# File contents",
3065                },
3066            },
3067            "dir_2": {
3068                "file_1.py": "# File 2_1 contents",
3069                "file_2.py": "# File 2_2 contents",
3070                "file_3.py": "# File 2_3 contents",
3071            },
3072            "always_included_but_ignored_dir": {
3073                "file_a.py": "# File contents",
3074                "file_b.py": "# File contents",
3075                "file_c.py": "# File contents",
3076            },
3077        }),
3078    )
3079    .await;
3080
3081    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3082    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3083    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3084    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3085
3086    assert_eq!(
3087        visible_entries_as_strings(&panel, 0..20, cx),
3088        &[
3089            "v project_root",
3090            "    > .git",
3091            "    > always_included_but_ignored_dir",
3092            "    > dir_1",
3093            "    > dir_2",
3094            "      .gitignore",
3095        ]
3096    );
3097
3098    let gitignored_dir_file =
3099        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3100    let always_included_but_ignored_dir_file = find_project_entry(
3101        &panel,
3102        "project_root/always_included_but_ignored_dir/file_a.py",
3103        cx,
3104    )
3105    .expect("file that is .gitignored but set to always be included should have an entry");
3106    assert_eq!(
3107        gitignored_dir_file, None,
3108        "File in the gitignored dir should not have an entry unless its directory is toggled"
3109    );
3110
3111    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3112    cx.run_until_parked();
3113    cx.update(|_, cx| {
3114        cx.update_global::<SettingsStore, _>(|store, cx| {
3115            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3116                project_panel_settings.auto_reveal_entries = Some(true)
3117            });
3118        })
3119    });
3120
3121    panel.update(cx, |panel, cx| {
3122        panel.project.update(cx, |_, cx| {
3123            cx.emit(project::Event::ActiveEntryChanged(Some(
3124                always_included_but_ignored_dir_file,
3125            )))
3126        })
3127    });
3128    cx.run_until_parked();
3129
3130    assert_eq!(
3131            visible_entries_as_strings(&panel, 0..20, cx),
3132            &[
3133                "v project_root",
3134                "    > .git",
3135                "    v always_included_but_ignored_dir",
3136                "          file_a.py  <== selected  <== marked",
3137                "          file_b.py",
3138                "          file_c.py",
3139                "    v dir_1",
3140                "        > gitignored_dir",
3141                "          file_1.py",
3142                "          file_2.py",
3143                "          file_3.py",
3144                "    > dir_2",
3145                "      .gitignore",
3146            ],
3147            "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3148        );
3149}
3150
3151#[gpui::test]
3152async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3153    init_test_with_editor(cx);
3154    cx.update(|cx| {
3155        cx.update_global::<SettingsStore, _>(|store, cx| {
3156            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3157                worktree_settings.file_scan_exclusions = Some(Vec::new());
3158            });
3159            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3160                project_panel_settings.auto_reveal_entries = Some(false)
3161            });
3162        })
3163    });
3164
3165    let fs = FakeFs::new(cx.background_executor.clone());
3166    fs.insert_tree(
3167        "/project_root",
3168        json!({
3169            ".git": {},
3170            ".gitignore": "**/gitignored_dir",
3171            "dir_1": {
3172                "file_1.py": "# File 1_1 contents",
3173                "file_2.py": "# File 1_2 contents",
3174                "file_3.py": "# File 1_3 contents",
3175                "gitignored_dir": {
3176                    "file_a.py": "# File contents",
3177                    "file_b.py": "# File contents",
3178                    "file_c.py": "# File contents",
3179                },
3180            },
3181            "dir_2": {
3182                "file_1.py": "# File 2_1 contents",
3183                "file_2.py": "# File 2_2 contents",
3184                "file_3.py": "# File 2_3 contents",
3185            }
3186        }),
3187    )
3188    .await;
3189
3190    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3191    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3192    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3193    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3194
3195    assert_eq!(
3196        visible_entries_as_strings(&panel, 0..20, cx),
3197        &[
3198            "v project_root",
3199            "    > .git",
3200            "    > dir_1",
3201            "    > dir_2",
3202            "      .gitignore",
3203        ]
3204    );
3205
3206    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3207        .expect("dir 1 file is not ignored and should have an entry");
3208    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3209        .expect("dir 2 file is not ignored and should have an entry");
3210    let gitignored_dir_file =
3211        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3212    assert_eq!(
3213        gitignored_dir_file, None,
3214        "File in the gitignored dir should not have an entry before its dir is toggled"
3215    );
3216
3217    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3218    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3219    cx.run_until_parked();
3220    assert_eq!(
3221        visible_entries_as_strings(&panel, 0..20, cx),
3222        &[
3223            "v project_root",
3224            "    > .git",
3225            "    v dir_1",
3226            "        v gitignored_dir  <== selected",
3227            "              file_a.py",
3228            "              file_b.py",
3229            "              file_c.py",
3230            "          file_1.py",
3231            "          file_2.py",
3232            "          file_3.py",
3233            "    > dir_2",
3234            "      .gitignore",
3235        ],
3236        "Should show gitignored dir file list in the project panel"
3237    );
3238    let gitignored_dir_file =
3239        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3240            .expect("after gitignored dir got opened, a file entry should be present");
3241
3242    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3243    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3244    assert_eq!(
3245        visible_entries_as_strings(&panel, 0..20, cx),
3246        &[
3247            "v project_root",
3248            "    > .git",
3249            "    > dir_1  <== selected",
3250            "    > dir_2",
3251            "      .gitignore",
3252        ],
3253        "Should hide all dir contents again and prepare for the explicit reveal test"
3254    );
3255
3256    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3257        panel.update(cx, |panel, cx| {
3258            panel.project.update(cx, |_, cx| {
3259                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3260            })
3261        });
3262        cx.run_until_parked();
3263        assert_eq!(
3264                visible_entries_as_strings(&panel, 0..20, cx),
3265                &[
3266                    "v project_root",
3267                    "    > .git",
3268                    "    > dir_1  <== selected",
3269                    "    > dir_2",
3270                    "      .gitignore",
3271                ],
3272                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3273            );
3274    }
3275
3276    panel.update(cx, |panel, cx| {
3277        panel.project.update(cx, |_, cx| {
3278            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3279        })
3280    });
3281    cx.run_until_parked();
3282    assert_eq!(
3283        visible_entries_as_strings(&panel, 0..20, cx),
3284        &[
3285            "v project_root",
3286            "    > .git",
3287            "    v dir_1",
3288            "        > gitignored_dir",
3289            "          file_1.py  <== selected  <== marked",
3290            "          file_2.py",
3291            "          file_3.py",
3292            "    > dir_2",
3293            "      .gitignore",
3294        ],
3295        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3296    );
3297
3298    panel.update(cx, |panel, cx| {
3299        panel.project.update(cx, |_, cx| {
3300            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3301        })
3302    });
3303    cx.run_until_parked();
3304    assert_eq!(
3305        visible_entries_as_strings(&panel, 0..20, cx),
3306        &[
3307            "v project_root",
3308            "    > .git",
3309            "    v dir_1",
3310            "        > gitignored_dir",
3311            "          file_1.py",
3312            "          file_2.py",
3313            "          file_3.py",
3314            "    v dir_2",
3315            "          file_1.py  <== selected  <== marked",
3316            "          file_2.py",
3317            "          file_3.py",
3318            "      .gitignore",
3319        ],
3320        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3321    );
3322
3323    panel.update(cx, |panel, cx| {
3324        panel.project.update(cx, |_, cx| {
3325            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3326        })
3327    });
3328    cx.run_until_parked();
3329    assert_eq!(
3330            visible_entries_as_strings(&panel, 0..20, cx),
3331            &[
3332                "v project_root",
3333                "    > .git",
3334                "    v dir_1",
3335                "        v gitignored_dir",
3336                "              file_a.py  <== selected  <== marked",
3337                "              file_b.py",
3338                "              file_c.py",
3339                "          file_1.py",
3340                "          file_2.py",
3341                "          file_3.py",
3342                "    v dir_2",
3343                "          file_1.py",
3344                "          file_2.py",
3345                "          file_3.py",
3346                "      .gitignore",
3347            ],
3348            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3349        );
3350}
3351
3352#[gpui::test]
3353async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
3354    init_test(cx);
3355    cx.update(|cx| {
3356        cx.update_global::<SettingsStore, _>(|store, cx| {
3357            store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
3358                project_settings.file_scan_exclusions =
3359                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
3360            });
3361        });
3362    });
3363
3364    cx.update(|cx| {
3365        register_project_item::<TestProjectItemView>(cx);
3366    });
3367
3368    let fs = FakeFs::new(cx.executor().clone());
3369    fs.insert_tree(
3370        "/root1",
3371        json!({
3372            ".dockerignore": "",
3373            ".git": {
3374                "HEAD": "",
3375            },
3376        }),
3377    )
3378    .await;
3379
3380    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3381    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3382    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3383    let panel = workspace
3384        .update(cx, |workspace, window, cx| {
3385            let panel = ProjectPanel::new(workspace, window, cx);
3386            workspace.add_panel(panel.clone(), window, cx);
3387            panel
3388        })
3389        .unwrap();
3390
3391    select_path(&panel, "root1", cx);
3392    assert_eq!(
3393        visible_entries_as_strings(&panel, 0..10, cx),
3394        &["v root1  <== selected", "      .dockerignore",]
3395    );
3396    workspace
3397        .update(cx, |workspace, _, cx| {
3398            assert!(
3399                workspace.active_item(cx).is_none(),
3400                "Should have no active items in the beginning"
3401            );
3402        })
3403        .unwrap();
3404
3405    let excluded_file_path = ".git/COMMIT_EDITMSG";
3406    let excluded_dir_path = "excluded_dir";
3407
3408    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3409    panel.update_in(cx, |panel, window, cx| {
3410        assert!(panel.filename_editor.read(cx).is_focused(window));
3411    });
3412    panel
3413        .update_in(cx, |panel, window, cx| {
3414            panel.filename_editor.update(cx, |editor, cx| {
3415                editor.set_text(excluded_file_path, window, cx)
3416            });
3417            panel.confirm_edit(window, cx).unwrap()
3418        })
3419        .await
3420        .unwrap();
3421
3422    assert_eq!(
3423        visible_entries_as_strings(&panel, 0..13, cx),
3424        &["v root1", "      .dockerignore"],
3425        "Excluded dir should not be shown after opening a file in it"
3426    );
3427    panel.update_in(cx, |panel, window, cx| {
3428        assert!(
3429            !panel.filename_editor.read(cx).is_focused(window),
3430            "Should have closed the file name editor"
3431        );
3432    });
3433    workspace
3434        .update(cx, |workspace, _, cx| {
3435            let active_entry_path = workspace
3436                .active_item(cx)
3437                .expect("should have opened and activated the excluded item")
3438                .act_as::<TestProjectItemView>(cx)
3439                .expect("should have opened the corresponding project item for the excluded item")
3440                .read(cx)
3441                .path
3442                .clone();
3443            assert_eq!(
3444                active_entry_path.path.as_ref(),
3445                Path::new(excluded_file_path),
3446                "Should open the excluded file"
3447            );
3448
3449            assert!(
3450                workspace.notification_ids().is_empty(),
3451                "Should have no notifications after opening an excluded file"
3452            );
3453        })
3454        .unwrap();
3455    assert!(
3456        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
3457        "Should have created the excluded file"
3458    );
3459
3460    select_path(&panel, "root1", cx);
3461    panel.update_in(cx, |panel, window, cx| {
3462        panel.new_directory(&NewDirectory, window, cx)
3463    });
3464    panel.update_in(cx, |panel, window, cx| {
3465        assert!(panel.filename_editor.read(cx).is_focused(window));
3466    });
3467    panel
3468        .update_in(cx, |panel, window, cx| {
3469            panel.filename_editor.update(cx, |editor, cx| {
3470                editor.set_text(excluded_file_path, window, cx)
3471            });
3472            panel.confirm_edit(window, cx).unwrap()
3473        })
3474        .await
3475        .unwrap();
3476
3477    assert_eq!(
3478            visible_entries_as_strings(&panel, 0..13, cx),
3479            &["v root1", "      .dockerignore"],
3480            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
3481        );
3482    panel.update_in(cx, |panel, window, cx| {
3483        assert!(
3484            !panel.filename_editor.read(cx).is_focused(window),
3485            "Should have closed the file name editor"
3486        );
3487    });
3488    workspace
3489        .update(cx, |workspace, _, cx| {
3490            let notifications = workspace.notification_ids();
3491            assert_eq!(
3492                notifications.len(),
3493                1,
3494                "Should receive one notification with the error message"
3495            );
3496            workspace.dismiss_notification(notifications.first().unwrap(), cx);
3497            assert!(workspace.notification_ids().is_empty());
3498        })
3499        .unwrap();
3500
3501    select_path(&panel, "root1", cx);
3502    panel.update_in(cx, |panel, window, cx| {
3503        panel.new_directory(&NewDirectory, window, cx)
3504    });
3505    panel.update_in(cx, |panel, window, cx| {
3506        assert!(panel.filename_editor.read(cx).is_focused(window));
3507    });
3508    panel
3509        .update_in(cx, |panel, window, cx| {
3510            panel.filename_editor.update(cx, |editor, cx| {
3511                editor.set_text(excluded_dir_path, window, cx)
3512            });
3513            panel.confirm_edit(window, cx).unwrap()
3514        })
3515        .await
3516        .unwrap();
3517
3518    assert_eq!(
3519        visible_entries_as_strings(&panel, 0..13, cx),
3520        &["v root1", "      .dockerignore"],
3521        "Should not change the project panel after trying to create an excluded directory"
3522    );
3523    panel.update_in(cx, |panel, window, cx| {
3524        assert!(
3525            !panel.filename_editor.read(cx).is_focused(window),
3526            "Should have closed the file name editor"
3527        );
3528    });
3529    workspace
3530        .update(cx, |workspace, _, cx| {
3531            let notifications = workspace.notification_ids();
3532            assert_eq!(
3533                notifications.len(),
3534                1,
3535                "Should receive one notification explaining that no directory is actually shown"
3536            );
3537            workspace.dismiss_notification(notifications.first().unwrap(), cx);
3538            assert!(workspace.notification_ids().is_empty());
3539        })
3540        .unwrap();
3541    assert!(
3542        fs.is_dir(Path::new("/root1/excluded_dir")).await,
3543        "Should have created the excluded directory"
3544    );
3545}
3546
3547#[gpui::test]
3548async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
3549    init_test_with_editor(cx);
3550
3551    let fs = FakeFs::new(cx.executor().clone());
3552    fs.insert_tree(
3553        "/src",
3554        json!({
3555            "test": {
3556                "first.rs": "// First Rust file",
3557                "second.rs": "// Second Rust file",
3558                "third.rs": "// Third Rust file",
3559            }
3560        }),
3561    )
3562    .await;
3563
3564    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3565    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3566    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3567    let panel = workspace
3568        .update(cx, |workspace, window, cx| {
3569            let panel = ProjectPanel::new(workspace, window, cx);
3570            workspace.add_panel(panel.clone(), window, cx);
3571            panel
3572        })
3573        .unwrap();
3574
3575    select_path(&panel, "src/", cx);
3576    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3577    cx.executor().run_until_parked();
3578    assert_eq!(
3579        visible_entries_as_strings(&panel, 0..10, cx),
3580        &[
3581            //
3582            "v src  <== selected",
3583            "    > test"
3584        ]
3585    );
3586    panel.update_in(cx, |panel, window, cx| {
3587        panel.new_directory(&NewDirectory, window, cx)
3588    });
3589    panel.update_in(cx, |panel, window, cx| {
3590        assert!(panel.filename_editor.read(cx).is_focused(window));
3591    });
3592    assert_eq!(
3593        visible_entries_as_strings(&panel, 0..10, cx),
3594        &[
3595            //
3596            "v src",
3597            "    > [EDITOR: '']  <== selected",
3598            "    > test"
3599        ]
3600    );
3601
3602    panel.update_in(cx, |panel, window, cx| {
3603        panel.cancel(&menu::Cancel, window, cx)
3604    });
3605    assert_eq!(
3606        visible_entries_as_strings(&panel, 0..10, cx),
3607        &[
3608            //
3609            "v src  <== selected",
3610            "    > test"
3611        ]
3612    );
3613}
3614
3615#[gpui::test]
3616async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
3617    init_test_with_editor(cx);
3618
3619    let fs = FakeFs::new(cx.executor().clone());
3620    fs.insert_tree(
3621        "/root",
3622        json!({
3623            "dir1": {
3624                "subdir1": {},
3625                "file1.txt": "",
3626                "file2.txt": "",
3627            },
3628            "dir2": {
3629                "subdir2": {},
3630                "file3.txt": "",
3631                "file4.txt": "",
3632            },
3633            "file5.txt": "",
3634            "file6.txt": "",
3635        }),
3636    )
3637    .await;
3638
3639    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3640    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3641    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3642    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3643
3644    toggle_expand_dir(&panel, "root/dir1", cx);
3645    toggle_expand_dir(&panel, "root/dir2", cx);
3646
3647    // Test Case 1: Delete middle file in directory
3648    select_path(&panel, "root/dir1/file1.txt", cx);
3649    assert_eq!(
3650        visible_entries_as_strings(&panel, 0..15, cx),
3651        &[
3652            "v root",
3653            "    v dir1",
3654            "        > subdir1",
3655            "          file1.txt  <== selected",
3656            "          file2.txt",
3657            "    v dir2",
3658            "        > subdir2",
3659            "          file3.txt",
3660            "          file4.txt",
3661            "      file5.txt",
3662            "      file6.txt",
3663        ],
3664        "Initial state before deleting middle file"
3665    );
3666
3667    submit_deletion(&panel, cx);
3668    assert_eq!(
3669        visible_entries_as_strings(&panel, 0..15, cx),
3670        &[
3671            "v root",
3672            "    v dir1",
3673            "        > subdir1",
3674            "          file2.txt  <== selected",
3675            "    v dir2",
3676            "        > subdir2",
3677            "          file3.txt",
3678            "          file4.txt",
3679            "      file5.txt",
3680            "      file6.txt",
3681        ],
3682        "Should select next file after deleting middle file"
3683    );
3684
3685    // Test Case 2: Delete last file in directory
3686    submit_deletion(&panel, cx);
3687    assert_eq!(
3688        visible_entries_as_strings(&panel, 0..15, cx),
3689        &[
3690            "v root",
3691            "    v dir1",
3692            "        > subdir1  <== selected",
3693            "    v dir2",
3694            "        > subdir2",
3695            "          file3.txt",
3696            "          file4.txt",
3697            "      file5.txt",
3698            "      file6.txt",
3699        ],
3700        "Should select next directory when last file is deleted"
3701    );
3702
3703    // Test Case 3: Delete root level file
3704    select_path(&panel, "root/file6.txt", cx);
3705    assert_eq!(
3706        visible_entries_as_strings(&panel, 0..15, cx),
3707        &[
3708            "v root",
3709            "    v dir1",
3710            "        > subdir1",
3711            "    v dir2",
3712            "        > subdir2",
3713            "          file3.txt",
3714            "          file4.txt",
3715            "      file5.txt",
3716            "      file6.txt  <== selected",
3717        ],
3718        "Initial state before deleting root level file"
3719    );
3720
3721    submit_deletion(&panel, cx);
3722    assert_eq!(
3723        visible_entries_as_strings(&panel, 0..15, cx),
3724        &[
3725            "v root",
3726            "    v dir1",
3727            "        > subdir1",
3728            "    v dir2",
3729            "        > subdir2",
3730            "          file3.txt",
3731            "          file4.txt",
3732            "      file5.txt  <== selected",
3733        ],
3734        "Should select prev entry at root level"
3735    );
3736}
3737
3738#[gpui::test]
3739async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
3740    init_test_with_editor(cx);
3741
3742    let fs = FakeFs::new(cx.executor().clone());
3743    fs.insert_tree(
3744        "/root",
3745        json!({
3746            "dir1": {
3747                "subdir1": {
3748                    "a.txt": "",
3749                    "b.txt": ""
3750                },
3751                "file1.txt": "",
3752            },
3753            "dir2": {
3754                "subdir2": {
3755                    "c.txt": "",
3756                    "d.txt": ""
3757                },
3758                "file2.txt": "",
3759            },
3760            "file3.txt": "",
3761        }),
3762    )
3763    .await;
3764
3765    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3766    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3767    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3768    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3769
3770    toggle_expand_dir(&panel, "root/dir1", cx);
3771    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3772    toggle_expand_dir(&panel, "root/dir2", cx);
3773    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
3774
3775    // Test Case 1: Select and delete nested directory with parent
3776    cx.simulate_modifiers_change(gpui::Modifiers {
3777        control: true,
3778        ..Default::default()
3779    });
3780    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
3781    select_path_with_mark(&panel, "root/dir1", cx);
3782
3783    assert_eq!(
3784        visible_entries_as_strings(&panel, 0..15, cx),
3785        &[
3786            "v root",
3787            "    v dir1  <== selected  <== marked",
3788            "        v subdir1  <== marked",
3789            "              a.txt",
3790            "              b.txt",
3791            "          file1.txt",
3792            "    v dir2",
3793            "        v subdir2",
3794            "              c.txt",
3795            "              d.txt",
3796            "          file2.txt",
3797            "      file3.txt",
3798        ],
3799        "Initial state before deleting nested directory with parent"
3800    );
3801
3802    submit_deletion(&panel, cx);
3803    assert_eq!(
3804        visible_entries_as_strings(&panel, 0..15, cx),
3805        &[
3806            "v root",
3807            "    v dir2  <== selected",
3808            "        v subdir2",
3809            "              c.txt",
3810            "              d.txt",
3811            "          file2.txt",
3812            "      file3.txt",
3813        ],
3814        "Should select next directory after deleting directory with parent"
3815    );
3816
3817    // Test Case 2: Select mixed files and directories across levels
3818    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
3819    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
3820    select_path_with_mark(&panel, "root/file3.txt", cx);
3821
3822    assert_eq!(
3823        visible_entries_as_strings(&panel, 0..15, cx),
3824        &[
3825            "v root",
3826            "    v dir2",
3827            "        v subdir2",
3828            "              c.txt  <== marked",
3829            "              d.txt",
3830            "          file2.txt  <== marked",
3831            "      file3.txt  <== selected  <== marked",
3832        ],
3833        "Initial state before deleting"
3834    );
3835
3836    submit_deletion(&panel, cx);
3837    assert_eq!(
3838        visible_entries_as_strings(&panel, 0..15, cx),
3839        &[
3840            "v root",
3841            "    v dir2  <== selected",
3842            "        v subdir2",
3843            "              d.txt",
3844        ],
3845        "Should select sibling directory"
3846    );
3847}
3848
3849#[gpui::test]
3850async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
3851    init_test_with_editor(cx);
3852
3853    let fs = FakeFs::new(cx.executor().clone());
3854    fs.insert_tree(
3855        "/root",
3856        json!({
3857            "dir1": {
3858                "subdir1": {
3859                    "a.txt": "",
3860                    "b.txt": ""
3861                },
3862                "file1.txt": "",
3863            },
3864            "dir2": {
3865                "subdir2": {
3866                    "c.txt": "",
3867                    "d.txt": ""
3868                },
3869                "file2.txt": "",
3870            },
3871            "file3.txt": "",
3872            "file4.txt": "",
3873        }),
3874    )
3875    .await;
3876
3877    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3878    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3879    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3880    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3881
3882    toggle_expand_dir(&panel, "root/dir1", cx);
3883    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3884    toggle_expand_dir(&panel, "root/dir2", cx);
3885    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
3886
3887    // Test Case 1: Select all root files and directories
3888    cx.simulate_modifiers_change(gpui::Modifiers {
3889        control: true,
3890        ..Default::default()
3891    });
3892    select_path_with_mark(&panel, "root/dir1", cx);
3893    select_path_with_mark(&panel, "root/dir2", cx);
3894    select_path_with_mark(&panel, "root/file3.txt", cx);
3895    select_path_with_mark(&panel, "root/file4.txt", cx);
3896    assert_eq!(
3897        visible_entries_as_strings(&panel, 0..20, cx),
3898        &[
3899            "v root",
3900            "    v dir1  <== marked",
3901            "        v subdir1",
3902            "              a.txt",
3903            "              b.txt",
3904            "          file1.txt",
3905            "    v dir2  <== marked",
3906            "        v subdir2",
3907            "              c.txt",
3908            "              d.txt",
3909            "          file2.txt",
3910            "      file3.txt  <== marked",
3911            "      file4.txt  <== selected  <== marked",
3912        ],
3913        "State before deleting all contents"
3914    );
3915
3916    submit_deletion(&panel, cx);
3917    assert_eq!(
3918        visible_entries_as_strings(&panel, 0..20, cx),
3919        &["v root  <== selected"],
3920        "Only empty root directory should remain after deleting all contents"
3921    );
3922}
3923
3924#[gpui::test]
3925async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
3926    init_test_with_editor(cx);
3927
3928    let fs = FakeFs::new(cx.executor().clone());
3929    fs.insert_tree(
3930        "/root",
3931        json!({
3932            "dir1": {
3933                "subdir1": {
3934                    "file_a.txt": "content a",
3935                    "file_b.txt": "content b",
3936                },
3937                "subdir2": {
3938                    "file_c.txt": "content c",
3939                },
3940                "file1.txt": "content 1",
3941            },
3942            "dir2": {
3943                "file2.txt": "content 2",
3944            },
3945        }),
3946    )
3947    .await;
3948
3949    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3950    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3951    let cx = &mut VisualTestContext::from_window(*workspace, cx);
3952    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3953
3954    toggle_expand_dir(&panel, "root/dir1", cx);
3955    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3956    toggle_expand_dir(&panel, "root/dir2", cx);
3957    cx.simulate_modifiers_change(gpui::Modifiers {
3958        control: true,
3959        ..Default::default()
3960    });
3961
3962    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
3963    select_path_with_mark(&panel, "root/dir1", cx);
3964    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
3965    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
3966
3967    assert_eq!(
3968        visible_entries_as_strings(&panel, 0..20, cx),
3969        &[
3970            "v root",
3971            "    v dir1  <== marked",
3972            "        v subdir1  <== marked",
3973            "              file_a.txt  <== selected  <== marked",
3974            "              file_b.txt",
3975            "        > subdir2",
3976            "          file1.txt",
3977            "    v dir2",
3978            "          file2.txt",
3979        ],
3980        "State with parent dir, subdir, and file selected"
3981    );
3982    submit_deletion(&panel, cx);
3983    assert_eq!(
3984        visible_entries_as_strings(&panel, 0..20, cx),
3985        &["v root", "    v dir2  <== selected", "          file2.txt",],
3986        "Only dir2 should remain after deletion"
3987    );
3988}
3989
3990#[gpui::test]
3991async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
3992    init_test_with_editor(cx);
3993
3994    let fs = FakeFs::new(cx.executor().clone());
3995    // First worktree
3996    fs.insert_tree(
3997        "/root1",
3998        json!({
3999            "dir1": {
4000                "file1.txt": "content 1",
4001                "file2.txt": "content 2",
4002            },
4003            "dir2": {
4004                "file3.txt": "content 3",
4005            },
4006        }),
4007    )
4008    .await;
4009
4010    // Second worktree
4011    fs.insert_tree(
4012        "/root2",
4013        json!({
4014            "dir3": {
4015                "file4.txt": "content 4",
4016                "file5.txt": "content 5",
4017            },
4018            "file6.txt": "content 6",
4019        }),
4020    )
4021    .await;
4022
4023    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4024    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4025    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4026    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4027
4028    // Expand all directories for testing
4029    toggle_expand_dir(&panel, "root1/dir1", cx);
4030    toggle_expand_dir(&panel, "root1/dir2", cx);
4031    toggle_expand_dir(&panel, "root2/dir3", cx);
4032
4033    // Test Case 1: Delete files across different worktrees
4034    cx.simulate_modifiers_change(gpui::Modifiers {
4035        control: true,
4036        ..Default::default()
4037    });
4038    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4039    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4040
4041    assert_eq!(
4042        visible_entries_as_strings(&panel, 0..20, cx),
4043        &[
4044            "v root1",
4045            "    v dir1",
4046            "          file1.txt  <== marked",
4047            "          file2.txt",
4048            "    v dir2",
4049            "          file3.txt",
4050            "v root2",
4051            "    v dir3",
4052            "          file4.txt  <== selected  <== marked",
4053            "          file5.txt",
4054            "      file6.txt",
4055        ],
4056        "Initial state with files selected from different worktrees"
4057    );
4058
4059    submit_deletion(&panel, cx);
4060    assert_eq!(
4061        visible_entries_as_strings(&panel, 0..20, cx),
4062        &[
4063            "v root1",
4064            "    v dir1",
4065            "          file2.txt",
4066            "    v dir2",
4067            "          file3.txt",
4068            "v root2",
4069            "    v dir3",
4070            "          file5.txt  <== selected",
4071            "      file6.txt",
4072        ],
4073        "Should select next file in the last worktree after deletion"
4074    );
4075
4076    // Test Case 2: Delete directories from different worktrees
4077    select_path_with_mark(&panel, "root1/dir1", cx);
4078    select_path_with_mark(&panel, "root2/dir3", cx);
4079
4080    assert_eq!(
4081        visible_entries_as_strings(&panel, 0..20, cx),
4082        &[
4083            "v root1",
4084            "    v dir1  <== marked",
4085            "          file2.txt",
4086            "    v dir2",
4087            "          file3.txt",
4088            "v root2",
4089            "    v dir3  <== selected  <== marked",
4090            "          file5.txt",
4091            "      file6.txt",
4092        ],
4093        "State with directories marked from different worktrees"
4094    );
4095
4096    submit_deletion(&panel, cx);
4097    assert_eq!(
4098        visible_entries_as_strings(&panel, 0..20, cx),
4099        &[
4100            "v root1",
4101            "    v dir2",
4102            "          file3.txt",
4103            "v root2",
4104            "      file6.txt  <== selected",
4105        ],
4106        "Should select remaining file in last worktree after directory deletion"
4107    );
4108
4109    // Test Case 4: Delete all remaining files except roots
4110    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4111    select_path_with_mark(&panel, "root2/file6.txt", cx);
4112
4113    assert_eq!(
4114        visible_entries_as_strings(&panel, 0..20, cx),
4115        &[
4116            "v root1",
4117            "    v dir2",
4118            "          file3.txt  <== marked",
4119            "v root2",
4120            "      file6.txt  <== selected  <== marked",
4121        ],
4122        "State with all remaining files marked"
4123    );
4124
4125    submit_deletion(&panel, cx);
4126    assert_eq!(
4127        visible_entries_as_strings(&panel, 0..20, cx),
4128        &["v root1", "    v dir2", "v root2  <== selected"],
4129        "Second parent root should be selected after deleting"
4130    );
4131}
4132
4133#[gpui::test]
4134async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4135    init_test_with_editor(cx);
4136
4137    let fs = FakeFs::new(cx.executor().clone());
4138    fs.insert_tree(
4139        "/root",
4140        json!({
4141            "dir1": {
4142                "file1.txt": "",
4143                "file2.txt": "",
4144                "file3.txt": "",
4145            },
4146            "dir2": {
4147                "file4.txt": "",
4148                "file5.txt": "",
4149            },
4150        }),
4151    )
4152    .await;
4153
4154    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4155    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4156    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4157    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4158
4159    toggle_expand_dir(&panel, "root/dir1", cx);
4160    toggle_expand_dir(&panel, "root/dir2", cx);
4161
4162    cx.simulate_modifiers_change(gpui::Modifiers {
4163        control: true,
4164        ..Default::default()
4165    });
4166
4167    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4168    select_path(&panel, "root/dir1/file1.txt", cx);
4169
4170    assert_eq!(
4171        visible_entries_as_strings(&panel, 0..15, cx),
4172        &[
4173            "v root",
4174            "    v dir1",
4175            "          file1.txt  <== selected",
4176            "          file2.txt  <== marked",
4177            "          file3.txt",
4178            "    v dir2",
4179            "          file4.txt",
4180            "          file5.txt",
4181        ],
4182        "Initial state with one marked entry and different selection"
4183    );
4184
4185    // Delete should operate on the selected entry (file1.txt)
4186    submit_deletion(&panel, cx);
4187    assert_eq!(
4188        visible_entries_as_strings(&panel, 0..15, cx),
4189        &[
4190            "v root",
4191            "    v dir1",
4192            "          file2.txt  <== selected  <== marked",
4193            "          file3.txt",
4194            "    v dir2",
4195            "          file4.txt",
4196            "          file5.txt",
4197        ],
4198        "Should delete selected file, not marked file"
4199    );
4200
4201    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
4202    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
4203    select_path(&panel, "root/dir2/file5.txt", cx);
4204
4205    assert_eq!(
4206        visible_entries_as_strings(&panel, 0..15, cx),
4207        &[
4208            "v root",
4209            "    v dir1",
4210            "          file2.txt  <== marked",
4211            "          file3.txt  <== marked",
4212            "    v dir2",
4213            "          file4.txt  <== marked",
4214            "          file5.txt  <== selected",
4215        ],
4216        "Initial state with multiple marked entries and different selection"
4217    );
4218
4219    // Delete should operate on all marked entries, ignoring the selection
4220    submit_deletion(&panel, cx);
4221    assert_eq!(
4222        visible_entries_as_strings(&panel, 0..15, cx),
4223        &[
4224            "v root",
4225            "    v dir1",
4226            "    v dir2",
4227            "          file5.txt  <== selected",
4228        ],
4229        "Should delete all marked files, leaving only the selected file"
4230    );
4231}
4232
4233#[gpui::test]
4234async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
4235    init_test_with_editor(cx);
4236
4237    let fs = FakeFs::new(cx.executor().clone());
4238    fs.insert_tree(
4239        "/root_b",
4240        json!({
4241            "dir1": {
4242                "file1.txt": "content 1",
4243                "file2.txt": "content 2",
4244            },
4245        }),
4246    )
4247    .await;
4248
4249    fs.insert_tree(
4250        "/root_c",
4251        json!({
4252            "dir2": {},
4253        }),
4254    )
4255    .await;
4256
4257    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
4258    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4259    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4260    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4261
4262    toggle_expand_dir(&panel, "root_b/dir1", cx);
4263    toggle_expand_dir(&panel, "root_c/dir2", cx);
4264
4265    cx.simulate_modifiers_change(gpui::Modifiers {
4266        control: true,
4267        ..Default::default()
4268    });
4269    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
4270    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
4271
4272    assert_eq!(
4273        visible_entries_as_strings(&panel, 0..20, cx),
4274        &[
4275            "v root_b",
4276            "    v dir1",
4277            "          file1.txt  <== marked",
4278            "          file2.txt  <== selected  <== marked",
4279            "v root_c",
4280            "    v dir2",
4281        ],
4282        "Initial state with files marked in root_b"
4283    );
4284
4285    submit_deletion(&panel, cx);
4286    assert_eq!(
4287        visible_entries_as_strings(&panel, 0..20, cx),
4288        &[
4289            "v root_b",
4290            "    v dir1  <== selected",
4291            "v root_c",
4292            "    v dir2",
4293        ],
4294        "After deletion in root_b as it's last deletion, selection should be in root_b"
4295    );
4296
4297    select_path_with_mark(&panel, "root_c/dir2", cx);
4298
4299    submit_deletion(&panel, cx);
4300    assert_eq!(
4301        visible_entries_as_strings(&panel, 0..20, cx),
4302        &["v root_b", "    v dir1", "v root_c  <== selected",],
4303        "After deleting from root_c, it should remain in root_c"
4304    );
4305}
4306
4307fn toggle_expand_dir(
4308    panel: &Entity<ProjectPanel>,
4309    path: impl AsRef<Path>,
4310    cx: &mut VisualTestContext,
4311) {
4312    let path = path.as_ref();
4313    panel.update_in(cx, |panel, window, cx| {
4314        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4315            let worktree = worktree.read(cx);
4316            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4317                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4318                panel.toggle_expanded(entry_id, window, cx);
4319                return;
4320            }
4321        }
4322        panic!("no worktree for path {:?}", path);
4323    });
4324}
4325
4326#[gpui::test]
4327async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
4328    init_test_with_editor(cx);
4329
4330    let fs = FakeFs::new(cx.executor().clone());
4331    fs.insert_tree(
4332        path!("/root"),
4333        json!({
4334            ".gitignore": "**/ignored_dir\n**/ignored_nested",
4335            "dir1": {
4336                "empty1": {
4337                    "empty2": {
4338                        "empty3": {
4339                            "file.txt": ""
4340                        }
4341                    }
4342                },
4343                "subdir1": {
4344                    "file1.txt": "",
4345                    "file2.txt": "",
4346                    "ignored_nested": {
4347                        "ignored_file.txt": ""
4348                    }
4349                },
4350                "ignored_dir": {
4351                    "subdir": {
4352                        "deep_file.txt": ""
4353                    }
4354                }
4355            }
4356        }),
4357    )
4358    .await;
4359
4360    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4361    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4362    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4363
4364    // Test 1: When auto-fold is enabled
4365    cx.update(|_, cx| {
4366        let settings = *ProjectPanelSettings::get_global(cx);
4367        ProjectPanelSettings::override_global(
4368            ProjectPanelSettings {
4369                auto_fold_dirs: true,
4370                ..settings
4371            },
4372            cx,
4373        );
4374    });
4375
4376    let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4377
4378    assert_eq!(
4379        visible_entries_as_strings(&panel, 0..20, cx),
4380        &["v root", "    > dir1", "      .gitignore",],
4381        "Initial state should show collapsed root structure"
4382    );
4383
4384    toggle_expand_dir(&panel, "root/dir1", cx);
4385    assert_eq!(
4386        visible_entries_as_strings(&panel, 0..20, cx),
4387        &[
4388            separator!("v root"),
4389            separator!("    v dir1  <== selected"),
4390            separator!("        > empty1/empty2/empty3"),
4391            separator!("        > ignored_dir"),
4392            separator!("        > subdir1"),
4393            separator!("      .gitignore"),
4394        ],
4395        "Should show first level with auto-folded dirs and ignored dir visible"
4396    );
4397
4398    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4399    panel.update(cx, |panel, cx| {
4400        let project = panel.project.read(cx);
4401        let worktree = project.worktrees(cx).next().unwrap().read(cx);
4402        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4403        panel.update_visible_entries(None, cx);
4404    });
4405    cx.run_until_parked();
4406
4407    assert_eq!(
4408            visible_entries_as_strings(&panel, 0..20, cx),
4409            &[
4410                separator!("v root"),
4411                separator!("    v dir1  <== selected"),
4412                separator!("        v empty1"),
4413                separator!("            v empty2"),
4414                separator!("                v empty3"),
4415                separator!("                      file.txt"),
4416                separator!("        > ignored_dir"),
4417                separator!("        v subdir1"),
4418                separator!("            > ignored_nested"),
4419                separator!("              file1.txt"),
4420                separator!("              file2.txt"),
4421                separator!("      .gitignore"),
4422            ],
4423            "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
4424        );
4425
4426    // Test 2: When auto-fold is disabled
4427    cx.update(|_, cx| {
4428        let settings = *ProjectPanelSettings::get_global(cx);
4429        ProjectPanelSettings::override_global(
4430            ProjectPanelSettings {
4431                auto_fold_dirs: false,
4432                ..settings
4433            },
4434            cx,
4435        );
4436    });
4437
4438    panel.update_in(cx, |panel, window, cx| {
4439        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
4440    });
4441
4442    toggle_expand_dir(&panel, "root/dir1", cx);
4443    assert_eq!(
4444        visible_entries_as_strings(&panel, 0..20, cx),
4445        &[
4446            separator!("v root"),
4447            separator!("    v dir1  <== selected"),
4448            separator!("        > empty1"),
4449            separator!("        > ignored_dir"),
4450            separator!("        > subdir1"),
4451            separator!("      .gitignore"),
4452        ],
4453        "With auto-fold disabled: should show all directories separately"
4454    );
4455
4456    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4457    panel.update(cx, |panel, cx| {
4458        let project = panel.project.read(cx);
4459        let worktree = project.worktrees(cx).next().unwrap().read(cx);
4460        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4461        panel.update_visible_entries(None, cx);
4462    });
4463    cx.run_until_parked();
4464
4465    assert_eq!(
4466        visible_entries_as_strings(&panel, 0..20, cx),
4467        &[
4468            separator!("v root"),
4469            separator!("    v dir1  <== selected"),
4470            separator!("        v empty1"),
4471            separator!("            v empty2"),
4472            separator!("                v empty3"),
4473            separator!("                      file.txt"),
4474            separator!("        > ignored_dir"),
4475            separator!("        v subdir1"),
4476            separator!("            > ignored_nested"),
4477            separator!("              file1.txt"),
4478            separator!("              file2.txt"),
4479            separator!("      .gitignore"),
4480        ],
4481        "After expand_all without auto-fold: should expand all dirs normally, \
4482         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
4483    );
4484
4485    // Test 3: When explicitly called on ignored directory
4486    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
4487    panel.update(cx, |panel, cx| {
4488        let project = panel.project.read(cx);
4489        let worktree = project.worktrees(cx).next().unwrap().read(cx);
4490        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
4491        panel.update_visible_entries(None, cx);
4492    });
4493    cx.run_until_parked();
4494
4495    assert_eq!(
4496        visible_entries_as_strings(&panel, 0..20, cx),
4497        &[
4498            separator!("v root"),
4499            separator!("    v dir1  <== selected"),
4500            separator!("        v empty1"),
4501            separator!("            v empty2"),
4502            separator!("                v empty3"),
4503            separator!("                      file.txt"),
4504            separator!("        v ignored_dir"),
4505            separator!("            v subdir"),
4506            separator!("                  deep_file.txt"),
4507            separator!("        v subdir1"),
4508            separator!("            > ignored_nested"),
4509            separator!("              file1.txt"),
4510            separator!("              file2.txt"),
4511            separator!("      .gitignore"),
4512        ],
4513        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
4514    );
4515}
4516
4517#[gpui::test]
4518async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
4519    init_test(cx);
4520
4521    let fs = FakeFs::new(cx.executor().clone());
4522    fs.insert_tree(
4523        path!("/root"),
4524        json!({
4525            "dir1": {
4526                "subdir1": {
4527                    "nested1": {
4528                        "file1.txt": "",
4529                        "file2.txt": ""
4530                    },
4531                },
4532                "subdir2": {
4533                    "file4.txt": ""
4534                }
4535            },
4536            "dir2": {
4537                "single_file": {
4538                    "file5.txt": ""
4539                }
4540            }
4541        }),
4542    )
4543    .await;
4544
4545    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4546    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4547    let cx = &mut VisualTestContext::from_window(*workspace, cx);
4548
4549    // Test 1: Basic collapsing
4550    {
4551        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4552
4553        toggle_expand_dir(&panel, "root/dir1", cx);
4554        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4555        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4556        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
4557
4558        assert_eq!(
4559            visible_entries_as_strings(&panel, 0..20, cx),
4560            &[
4561                separator!("v root"),
4562                separator!("    v dir1"),
4563                separator!("        v subdir1"),
4564                separator!("            v nested1"),
4565                separator!("                  file1.txt"),
4566                separator!("                  file2.txt"),
4567                separator!("        v subdir2  <== selected"),
4568                separator!("              file4.txt"),
4569                separator!("    > dir2"),
4570            ],
4571            "Initial state with everything expanded"
4572        );
4573
4574        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4575        panel.update(cx, |panel, cx| {
4576            let project = panel.project.read(cx);
4577            let worktree = project.worktrees(cx).next().unwrap().read(cx);
4578            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4579            panel.update_visible_entries(None, cx);
4580        });
4581
4582        assert_eq!(
4583            visible_entries_as_strings(&panel, 0..20, cx),
4584            &["v root", "    > dir1", "    > dir2",],
4585            "All subdirs under dir1 should be collapsed"
4586        );
4587    }
4588
4589    // Test 2: With auto-fold enabled
4590    {
4591        cx.update(|_, cx| {
4592            let settings = *ProjectPanelSettings::get_global(cx);
4593            ProjectPanelSettings::override_global(
4594                ProjectPanelSettings {
4595                    auto_fold_dirs: true,
4596                    ..settings
4597                },
4598                cx,
4599            );
4600        });
4601
4602        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4603
4604        toggle_expand_dir(&panel, "root/dir1", cx);
4605        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4606        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4607
4608        assert_eq!(
4609            visible_entries_as_strings(&panel, 0..20, cx),
4610            &[
4611                separator!("v root"),
4612                separator!("    v dir1"),
4613                separator!("        v subdir1/nested1  <== selected"),
4614                separator!("              file1.txt"),
4615                separator!("              file2.txt"),
4616                separator!("        > subdir2"),
4617                separator!("    > dir2/single_file"),
4618            ],
4619            "Initial state with some dirs expanded"
4620        );
4621
4622        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4623        panel.update(cx, |panel, cx| {
4624            let project = panel.project.read(cx);
4625            let worktree = project.worktrees(cx).next().unwrap().read(cx);
4626            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4627        });
4628
4629        toggle_expand_dir(&panel, "root/dir1", cx);
4630
4631        assert_eq!(
4632            visible_entries_as_strings(&panel, 0..20, cx),
4633            &[
4634                separator!("v root"),
4635                separator!("    v dir1  <== selected"),
4636                separator!("        > subdir1/nested1"),
4637                separator!("        > subdir2"),
4638                separator!("    > dir2/single_file"),
4639            ],
4640            "Subdirs should be collapsed and folded with auto-fold enabled"
4641        );
4642    }
4643
4644    // Test 3: With auto-fold disabled
4645    {
4646        cx.update(|_, cx| {
4647            let settings = *ProjectPanelSettings::get_global(cx);
4648            ProjectPanelSettings::override_global(
4649                ProjectPanelSettings {
4650                    auto_fold_dirs: false,
4651                    ..settings
4652                },
4653                cx,
4654            );
4655        });
4656
4657        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4658
4659        toggle_expand_dir(&panel, "root/dir1", cx);
4660        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4661        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4662
4663        assert_eq!(
4664            visible_entries_as_strings(&panel, 0..20, cx),
4665            &[
4666                separator!("v root"),
4667                separator!("    v dir1"),
4668                separator!("        v subdir1"),
4669                separator!("            v nested1  <== selected"),
4670                separator!("                  file1.txt"),
4671                separator!("                  file2.txt"),
4672                separator!("        > subdir2"),
4673                separator!("    > dir2"),
4674            ],
4675            "Initial state with some dirs expanded and auto-fold disabled"
4676        );
4677
4678        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4679        panel.update(cx, |panel, cx| {
4680            let project = panel.project.read(cx);
4681            let worktree = project.worktrees(cx).next().unwrap().read(cx);
4682            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4683        });
4684
4685        toggle_expand_dir(&panel, "root/dir1", cx);
4686
4687        assert_eq!(
4688            visible_entries_as_strings(&panel, 0..20, cx),
4689            &[
4690                separator!("v root"),
4691                separator!("    v dir1  <== selected"),
4692                separator!("        > subdir1"),
4693                separator!("        > subdir2"),
4694                separator!("    > dir2"),
4695            ],
4696            "Subdirs should be collapsed but not folded with auto-fold disabled"
4697        );
4698    }
4699}
4700
4701fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4702    let path = path.as_ref();
4703    panel.update(cx, |panel, cx| {
4704        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4705            let worktree = worktree.read(cx);
4706            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4707                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4708                panel.selection = Some(crate::SelectedEntry {
4709                    worktree_id: worktree.id(),
4710                    entry_id,
4711                });
4712                return;
4713            }
4714        }
4715        panic!("no worktree for path {:?}", path);
4716    });
4717}
4718
4719fn select_path_with_mark(
4720    panel: &Entity<ProjectPanel>,
4721    path: impl AsRef<Path>,
4722    cx: &mut VisualTestContext,
4723) {
4724    let path = path.as_ref();
4725    panel.update(cx, |panel, cx| {
4726        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4727            let worktree = worktree.read(cx);
4728            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4729                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4730                let entry = crate::SelectedEntry {
4731                    worktree_id: worktree.id(),
4732                    entry_id,
4733                };
4734                if !panel.marked_entries.contains(&entry) {
4735                    panel.marked_entries.insert(entry);
4736                }
4737                panel.selection = Some(entry);
4738                return;
4739            }
4740        }
4741        panic!("no worktree for path {:?}", path);
4742    });
4743}
4744
4745fn find_project_entry(
4746    panel: &Entity<ProjectPanel>,
4747    path: impl AsRef<Path>,
4748    cx: &mut VisualTestContext,
4749) -> Option<ProjectEntryId> {
4750    let path = path.as_ref();
4751    panel.update(cx, |panel, cx| {
4752        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4753            let worktree = worktree.read(cx);
4754            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4755                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4756            }
4757        }
4758        panic!("no worktree for path {path:?}");
4759    })
4760}
4761
4762fn visible_entries_as_strings(
4763    panel: &Entity<ProjectPanel>,
4764    range: Range<usize>,
4765    cx: &mut VisualTestContext,
4766) -> Vec<String> {
4767    let mut result = Vec::new();
4768    let mut project_entries = HashSet::default();
4769    let mut has_editor = false;
4770
4771    panel.update_in(cx, |panel, window, cx| {
4772        panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
4773            if details.is_editing {
4774                assert!(!has_editor, "duplicate editor entry");
4775                has_editor = true;
4776            } else {
4777                assert!(
4778                    project_entries.insert(project_entry),
4779                    "duplicate project entry {:?} {:?}",
4780                    project_entry,
4781                    details
4782                );
4783            }
4784
4785            let indent = "    ".repeat(details.depth);
4786            let icon = if details.kind.is_dir() {
4787                if details.is_expanded {
4788                    "v "
4789                } else {
4790                    "> "
4791                }
4792            } else {
4793                "  "
4794            };
4795            let name = if details.is_editing {
4796                format!("[EDITOR: '{}']", details.filename)
4797            } else if details.is_processing {
4798                format!("[PROCESSING: '{}']", details.filename)
4799            } else {
4800                details.filename.clone()
4801            };
4802            let selected = if details.is_selected {
4803                "  <== selected"
4804            } else {
4805                ""
4806            };
4807            let marked = if details.is_marked {
4808                "  <== marked"
4809            } else {
4810                ""
4811            };
4812
4813            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4814        });
4815    });
4816
4817    result
4818}
4819
4820fn init_test(cx: &mut TestAppContext) {
4821    cx.update(|cx| {
4822        let settings_store = SettingsStore::test(cx);
4823        cx.set_global(settings_store);
4824        init_settings(cx);
4825        theme::init(theme::LoadThemes::JustBase, cx);
4826        language::init(cx);
4827        editor::init_settings(cx);
4828        crate::init(cx);
4829        workspace::init_settings(cx);
4830        client::init_settings(cx);
4831        Project::init_settings(cx);
4832
4833        cx.update_global::<SettingsStore, _>(|store, cx| {
4834            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4835                project_panel_settings.auto_fold_dirs = Some(false);
4836            });
4837            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4838                worktree_settings.file_scan_exclusions = Some(Vec::new());
4839            });
4840        });
4841    });
4842}
4843
4844fn init_test_with_editor(cx: &mut TestAppContext) {
4845    cx.update(|cx| {
4846        let app_state = AppState::test(cx);
4847        theme::init(theme::LoadThemes::JustBase, cx);
4848        init_settings(cx);
4849        language::init(cx);
4850        editor::init(cx);
4851        crate::init(cx);
4852        workspace::init(app_state.clone(), cx);
4853        Project::init_settings(cx);
4854
4855        cx.update_global::<SettingsStore, _>(|store, cx| {
4856            store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4857                project_panel_settings.auto_fold_dirs = Some(false);
4858            });
4859            store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4860                worktree_settings.file_scan_exclusions = Some(Vec::new());
4861            });
4862        });
4863    });
4864}
4865
4866fn ensure_single_file_is_opened(
4867    window: &WindowHandle<Workspace>,
4868    expected_path: &str,
4869    cx: &mut TestAppContext,
4870) {
4871    window
4872        .update(cx, |workspace, _, cx| {
4873            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4874            assert_eq!(worktrees.len(), 1);
4875            let worktree_id = worktrees[0].read(cx).id();
4876
4877            let open_project_paths = workspace
4878                .panes()
4879                .iter()
4880                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4881                .collect::<Vec<_>>();
4882            assert_eq!(
4883                open_project_paths,
4884                vec![ProjectPath {
4885                    worktree_id,
4886                    path: Arc::from(Path::new(expected_path))
4887                }],
4888                "Should have opened file, selected in project panel"
4889            );
4890        })
4891        .unwrap();
4892}
4893
4894fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
4895    assert!(
4896        !cx.has_pending_prompt(),
4897        "Should have no prompts before the deletion"
4898    );
4899    panel.update_in(cx, |panel, window, cx| {
4900        panel.delete(&Delete { skip_prompt: false }, window, cx)
4901    });
4902    assert!(
4903        cx.has_pending_prompt(),
4904        "Should have a prompt after the deletion"
4905    );
4906    cx.simulate_prompt_answer("Delete");
4907    assert!(
4908        !cx.has_pending_prompt(),
4909        "Should have no prompts after prompt was replied to"
4910    );
4911    cx.executor().run_until_parked();
4912}
4913
4914fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
4915    assert!(
4916        !cx.has_pending_prompt(),
4917        "Should have no prompts before the deletion"
4918    );
4919    panel.update_in(cx, |panel, window, cx| {
4920        panel.delete(&Delete { skip_prompt: true }, window, cx)
4921    });
4922    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4923    cx.executor().run_until_parked();
4924}
4925
4926fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
4927    assert!(
4928        !cx.has_pending_prompt(),
4929        "Should have no prompts after deletion operation closes the file"
4930    );
4931    workspace
4932            .read_with(cx, |workspace, cx| {
4933                let open_project_paths = workspace
4934                    .panes()
4935                    .iter()
4936                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4937                    .collect::<Vec<_>>();
4938                assert!(
4939                    open_project_paths.is_empty(),
4940                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4941                );
4942            })
4943            .unwrap();
4944}
4945
4946struct TestProjectItemView {
4947    focus_handle: FocusHandle,
4948    path: ProjectPath,
4949}
4950
4951struct TestProjectItem {
4952    path: ProjectPath,
4953}
4954
4955impl project::ProjectItem for TestProjectItem {
4956    fn try_open(
4957        _project: &Entity<Project>,
4958        path: &ProjectPath,
4959        cx: &mut App,
4960    ) -> Option<Task<gpui::Result<Entity<Self>>>> {
4961        let path = path.clone();
4962        Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
4963    }
4964
4965    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
4966        None
4967    }
4968
4969    fn project_path(&self, _: &App) -> Option<ProjectPath> {
4970        Some(self.path.clone())
4971    }
4972
4973    fn is_dirty(&self) -> bool {
4974        false
4975    }
4976}
4977
4978impl ProjectItem for TestProjectItemView {
4979    type Item = TestProjectItem;
4980
4981    fn for_project_item(
4982        _: Entity<Project>,
4983        project_item: Entity<Self::Item>,
4984        _: &mut Window,
4985        cx: &mut Context<Self>,
4986    ) -> Self
4987    where
4988        Self: Sized,
4989    {
4990        Self {
4991            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
4992            focus_handle: cx.focus_handle(),
4993        }
4994    }
4995}
4996
4997impl Item for TestProjectItemView {
4998    type Event = ();
4999}
5000
5001impl EventEmitter<()> for TestProjectItemView {}
5002
5003impl Focusable for TestProjectItemView {
5004    fn focus_handle(&self, _: &App) -> FocusHandle {
5005        self.focus_handle.clone()
5006    }
5007}
5008
5009impl Render for TestProjectItemView {
5010    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5011        Empty
5012    }
5013}