Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:15:39.196Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

project_panel_tests.rs

11343 lines · 360.7 KB · rust
1use super::*;
2// use crate::undo::tests::{build_create_operation, build_rename_operation};
3use collections::HashSet;
4use editor::{Editor, MultiBufferOffset};
5use git::{
6    Oid,
7    repository::{InitialGraphCommitData, LogSource, RepoPath},
8};
9use gpui::{Empty, Entity, TestAppContext, VisualTestContext};
10use menu::Cancel;
11use pretty_assertions::assert_eq;
12use project::{FakeFs, ProjectPath};
13use serde_json::json;
14use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
15use smallvec::smallvec;
16use std::path::{Path, PathBuf};
17use util::{path, paths::PathStyle, rel_path::rel_path};
18use workspace::{
19    AppState, ItemHandle, MultiWorkspace, Pane, Workspace,
20    item::{Item, ProjectItem, test::TestItem},
21    register_project_item,
22};
23
24#[gpui::test]
25async fn test_visible_list(cx: &mut gpui::TestAppContext) {
26    init_test(cx);
27
28    let fs = FakeFs::new(cx.executor());
29    fs.insert_tree(
30        "/root1",
31        json!({
32            ".dockerignore": "",
33            ".git": {
34                "HEAD": "",
35            },
36            "a": {
37                "0": { "q": "", "r": "", "s": "" },
38                "1": { "t": "", "u": "" },
39                "2": { "v": "", "w": "", "x": "", "y": "" },
40            },
41            "b": {
42                "3": { "Q": "" },
43                "4": { "R": "", "S": "", "T": "", "U": "" },
44            },
45            "C": {
46                "5": {},
47                "6": { "V": "", "W": "" },
48                "7": { "X": "" },
49                "8": { "Y": {}, "Z": "" }
50            }
51        }),
52    )
53    .await;
54    fs.insert_tree(
55        "/root2",
56        json!({
57            "d": {
58                "9": ""
59            },
60            "e": {}
61        }),
62    )
63    .await;
64
65    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
66    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
67    let workspace = window
68        .read_with(cx, |mw, _| mw.workspace().clone())
69        .unwrap();
70    let cx = &mut VisualTestContext::from_window(window.into(), cx);
71    let panel = workspace.update_in(cx, ProjectPanel::new);
72    cx.run_until_parked();
73    assert_eq!(
74        visible_entries_as_strings(&panel, 0..50, cx),
75        &[
76            "v root1",
77            "    > .git",
78            "    > a",
79            "    > b",
80            "    > C",
81            "      .dockerignore",
82            "v root2",
83            "    > d",
84            "    > e",
85        ]
86    );
87
88    toggle_expand_dir(&panel, "root1/b", cx);
89    assert_eq!(
90        visible_entries_as_strings(&panel, 0..50, cx),
91        &[
92            "v root1",
93            "    > .git",
94            "    > a",
95            "    v b  <== selected",
96            "        > 3",
97            "        > 4",
98            "    > C",
99            "      .dockerignore",
100            "v root2",
101            "    > d",
102            "    > e",
103        ]
104    );
105
106    assert_eq!(
107        visible_entries_as_strings(&panel, 6..9, cx),
108        &[
109            //
110            "    > C",
111            "      .dockerignore",
112            "v root2",
113        ]
114    );
115}
116
117#[gpui::test]
118async fn test_opening_file(cx: &mut gpui::TestAppContext) {
119    init_test_with_editor(cx);
120
121    let fs = FakeFs::new(cx.executor());
122    fs.insert_tree(
123        path!("/src"),
124        json!({
125            "test": {
126                "first.rs": "// First Rust file",
127                "second.rs": "// Second Rust file",
128                "third.rs": "// Third Rust file",
129            }
130        }),
131    )
132    .await;
133
134    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
135    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
136    let workspace = window
137        .read_with(cx, |mw, _| mw.workspace().clone())
138        .unwrap();
139    let cx = &mut VisualTestContext::from_window(window.into(), cx);
140    let panel = workspace.update_in(cx, ProjectPanel::new);
141    cx.run_until_parked();
142
143    toggle_expand_dir(&panel, "src/test", cx);
144    select_path(&panel, "src/test/first.rs", cx);
145    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
146    cx.executor().run_until_parked();
147    assert_eq!(
148        visible_entries_as_strings(&panel, 0..10, cx),
149        &[
150            "v src",
151            "    v test",
152            "          first.rs  <== selected  <== marked",
153            "          second.rs",
154            "          third.rs"
155        ]
156    );
157    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
158
159    select_path(&panel, "src/test/second.rs", cx);
160    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
161    cx.executor().run_until_parked();
162    assert_eq!(
163        visible_entries_as_strings(&panel, 0..10, cx),
164        &[
165            "v src",
166            "    v test",
167            "          first.rs",
168            "          second.rs  <== selected  <== marked",
169            "          third.rs"
170        ]
171    );
172    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
173}
174
175#[gpui::test]
176async fn test_file_history_action_uses_focused_project_panel_selection(
177    cx: &mut gpui::TestAppContext,
178) {
179    init_test_with_git_ui(cx);
180
181    let fs = FakeFs::new(cx.executor());
182    fs.insert_tree(
183        Path::new("/project"),
184        json!({
185            ".git": {},
186            "tracked1.txt": "tracked 1",
187            "tracked2.txt": "tracked 2",
188        }),
189    )
190    .await;
191
192    let commits = vec![Arc::new(InitialGraphCommitData {
193        sha: Oid::from_bytes(&[1; 20]).unwrap(),
194        parents: smallvec![],
195        ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
196    })];
197    fs.set_graph_commits(Path::new("/project/.git"), commits);
198
199    let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
200    cx.run_until_parked();
201
202    let repository = project.read_with(cx, |project, cx| {
203        project
204            .active_repository(cx)
205            .expect("should have active repository")
206    });
207    let project_panel_repo_path = RepoPath::new(&"tracked1.txt").unwrap();
208    let editor_repo_path = RepoPath::new(&"tracked2.txt").unwrap();
209    let project_panel_path = repository
210        .read_with(cx, |repository, cx| {
211            repository.repo_path_to_project_path(&project_panel_repo_path, cx)
212        })
213        .expect("project panel path should resolve");
214    let editor_path = repository
215        .read_with(cx, |repository, cx| {
216            repository.repo_path_to_project_path(&editor_repo_path, cx)
217        })
218        .expect("editor path should resolve");
219
220    let (multi_workspace, cx) =
221        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
222    let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| {
223        multi_workspace.workspace().clone()
224    });
225    let project_panel = multi_workspace.update_in(cx, |multi_workspace, window, cx| {
226        let workspace = multi_workspace.workspace();
227        workspace.update(cx, |workspace, cx| {
228            let project_panel = ProjectPanel::new(workspace, window, cx);
229            workspace.add_panel(project_panel.clone(), window, cx);
230            project_panel
231        })
232    });
233    cx.run_until_parked();
234
235    let editor_buffer = project
236        .update(cx, |project, cx| {
237            project.open_buffer(editor_path.clone(), cx)
238        })
239        .await
240        .expect("editor buffer should open");
241    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
242        let workspace = multi_workspace.workspace();
243        let multibuffer = cx.new(|cx| {
244            let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite);
245            multibuffer.set_excerpts_for_buffer(
246                editor_buffer.clone(),
247                [Default::default()..editor_buffer.read(cx).max_point()],
248                0,
249                cx,
250            );
251            multibuffer
252        });
253        let editor =
254            cx.new(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx));
255        workspace.update(cx, |workspace, cx| {
256            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
257        });
258        editor.update(cx, |editor, cx| {
259            window.focus(&editor.focus_handle(cx), cx);
260        });
261    });
262    cx.run_until_parked();
263
264    multi_workspace.update_in(cx, |_multi_workspace, window, cx| {
265        project_panel.update(cx, |panel, cx| {
266            panel.select_path_for_test(project_panel_path.clone(), cx);
267        });
268        project_panel.update(cx, |panel, cx| {
269            panel.focus_handle(cx).focus(window, cx);
270        });
271    });
272    cx.run_until_parked();
273
274    cx.update(|window, cx| {
275        window.dispatch_action(Box::new(git::FileHistory), cx);
276    });
277    cx.run_until_parked();
278
279    workspace.read_with(&*cx, |workspace, cx| {
280        let graphs = workspace
281            .items_of_type::<git_ui::git_graph::GitGraph>(cx)
282            .collect::<Vec<_>>();
283        assert_eq!(graphs.len(), 1);
284        assert_eq!(
285            graphs[0].read(cx).log_source_for_test(),
286            &LogSource::Path(project_panel_repo_path)
287        );
288    });
289}
290
291#[gpui::test]
292async fn test_file_history_action_does_not_fall_back_to_editor_when_focused_project_panel_selection_has_no_git_repo(
293    cx: &mut gpui::TestAppContext,
294) {
295    init_test_with_git_ui(cx);
296
297    let fs = FakeFs::new(cx.executor());
298    fs.insert_tree(
299        Path::new("/git-project"),
300        json!({
301            ".git": {},
302            "tracked.txt": "tracked",
303        }),
304    )
305    .await;
306    fs.insert_tree(
307        Path::new("/plain-project"),
308        json!({
309            "plain.txt": "plain",
310        }),
311    )
312    .await;
313
314    fs.set_graph_commits(
315        Path::new("/git-project/.git"),
316        vec![Arc::new(InitialGraphCommitData {
317            sha: Oid::from_bytes(&[1; 20]).unwrap(),
318            parents: smallvec![],
319            ref_names: vec!["HEAD".into(), "refs/heads/main".into()],
320        })],
321    );
322
323    let project = Project::test(
324        fs.clone(),
325        [Path::new("/git-project"), Path::new("/plain-project")],
326        cx,
327    )
328    .await;
329    cx.run_until_parked();
330
331    let repository = project.read_with(cx, |project, cx| {
332        project
333            .active_repository(cx)
334            .expect("should have active repository")
335    });
336    let editor_repo_path = RepoPath::new(&"tracked.txt").unwrap();
337    let editor_path = repository
338        .read_with(cx, |repository, cx| {
339            repository.repo_path_to_project_path(&editor_repo_path, cx)
340        })
341        .expect("editor path should resolve");
342    let plain_worktree_id = project.read_with(cx, |project, cx| {
343        project
344            .worktree_for_root_name("plain-project", cx)
345            .expect("plain worktree should exist")
346            .read(cx)
347            .id()
348    });
349    let plain_project_path = ProjectPath {
350        worktree_id: plain_worktree_id,
351        path: rel_path("plain.txt").into(),
352    };
353
354    let (multi_workspace, cx) =
355        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
356    let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| {
357        multi_workspace.workspace().clone()
358    });
359    let project_panel = multi_workspace.update_in(cx, |multi_workspace, window, cx| {
360        let workspace = multi_workspace.workspace();
361        workspace.update(cx, |workspace, cx| {
362            let project_panel = ProjectPanel::new(workspace, window, cx);
363            workspace.add_panel(project_panel.clone(), window, cx);
364            project_panel
365        })
366    });
367    cx.run_until_parked();
368
369    let editor_buffer = project
370        .update(cx, |project, cx| {
371            project.open_buffer(editor_path.clone(), cx)
372        })
373        .await
374        .expect("editor buffer should open");
375    multi_workspace.update_in(cx, |multi_workspace, window, cx| {
376        let workspace = multi_workspace.workspace();
377        let multibuffer = cx.new(|cx| {
378            let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite);
379            multibuffer.set_excerpts_for_buffer(
380                editor_buffer.clone(),
381                [Default::default()..editor_buffer.read(cx).max_point()],
382                0,
383                cx,
384            );
385            multibuffer
386        });
387        let editor =
388            cx.new(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx));
389        workspace.update(cx, |workspace, cx| {
390            workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
391        });
392        editor.update(cx, |editor, cx| {
393            window.focus(&editor.focus_handle(cx), cx);
394        });
395    });
396    cx.run_until_parked();
397
398    multi_workspace.update_in(cx, |_multi_workspace, window, cx| {
399        project_panel.update(cx, |panel, cx| {
400            panel.select_path_for_test(plain_project_path.clone(), cx);
401        });
402        project_panel.update(cx, |panel, cx| {
403            panel.focus_handle(cx).focus(window, cx);
404        });
405    });
406    cx.run_until_parked();
407
408    cx.update(|window, cx| {
409        window.dispatch_action(Box::new(git::FileHistory), cx);
410    });
411    cx.run_until_parked();
412
413    workspace.read_with(&*cx, |workspace, cx| {
414        assert_eq!(
415            workspace
416                .items_of_type::<git_ui::git_graph::GitGraph>(cx)
417                .count(),
418            0
419        );
420    });
421}
422
423#[gpui::test]
424async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
425    init_test(cx);
426    cx.update(|cx| {
427        cx.update_global::<SettingsStore, _>(|store, cx| {
428            store.update_user_settings(cx, |settings| {
429                settings.project.worktree.file_scan_exclusions =
430                    Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
431            });
432        });
433    });
434
435    let fs = FakeFs::new(cx.background_executor.clone());
436    fs.insert_tree(
437        "/root1",
438        json!({
439            ".dockerignore": "",
440            ".git": {
441                "HEAD": "",
442            },
443            "a": {
444                "0": { "q": "", "r": "", "s": "" },
445                "1": { "t": "", "u": "" },
446                "2": { "v": "", "w": "", "x": "", "y": "" },
447            },
448            "b": {
449                "3": { "Q": "" },
450                "4": { "R": "", "S": "", "T": "", "U": "" },
451            },
452            "C": {
453                "5": {},
454                "6": { "V": "", "W": "" },
455                "7": { "X": "" },
456                "8": { "Y": {}, "Z": "" }
457            }
458        }),
459    )
460    .await;
461    fs.insert_tree(
462        "/root2",
463        json!({
464            "d": {
465                "4": ""
466            },
467            "e": {}
468        }),
469    )
470    .await;
471
472    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
473    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
474    let workspace = window
475        .read_with(cx, |mw, _| mw.workspace().clone())
476        .unwrap();
477    let cx = &mut VisualTestContext::from_window(window.into(), cx);
478    let panel = workspace.update_in(cx, ProjectPanel::new);
479    cx.run_until_parked();
480    assert_eq!(
481        visible_entries_as_strings(&panel, 0..50, cx),
482        &[
483            "v root1",
484            "    > a",
485            "    > b",
486            "    > C",
487            "      .dockerignore",
488            "v root2",
489            "    > d",
490            "    > e",
491        ]
492    );
493
494    toggle_expand_dir(&panel, "root1/b", cx);
495    assert_eq!(
496        visible_entries_as_strings(&panel, 0..50, cx),
497        &[
498            "v root1",
499            "    > a",
500            "    v b  <== selected",
501            "        > 3",
502            "    > C",
503            "      .dockerignore",
504            "v root2",
505            "    > d",
506            "    > e",
507        ]
508    );
509
510    toggle_expand_dir(&panel, "root2/d", cx);
511    assert_eq!(
512        visible_entries_as_strings(&panel, 0..50, cx),
513        &[
514            "v root1",
515            "    > a",
516            "    v b",
517            "        > 3",
518            "    > C",
519            "      .dockerignore",
520            "v root2",
521            "    v d  <== selected",
522            "    > e",
523        ]
524    );
525
526    toggle_expand_dir(&panel, "root2/e", cx);
527    assert_eq!(
528        visible_entries_as_strings(&panel, 0..50, cx),
529        &[
530            "v root1",
531            "    > a",
532            "    v b",
533            "        > 3",
534            "    > C",
535            "      .dockerignore",
536            "v root2",
537            "    v d",
538            "    v e  <== selected",
539        ]
540    );
541}
542
543#[gpui::test]
544async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
545    init_test(cx);
546
547    let fs = FakeFs::new(cx.executor());
548    fs.insert_tree(
549        path!("/root1"),
550        json!({
551            "dir_1": {
552                "nested_dir_1": {
553                    "nested_dir_2": {
554                        "nested_dir_3": {
555                            "file_a.java": "// File contents",
556                            "file_b.java": "// File contents",
557                            "file_c.java": "// File contents",
558                            "nested_dir_4": {
559                                "nested_dir_5": {
560                                    "file_d.java": "// File contents",
561                                }
562                            }
563                        }
564                    }
565                }
566            }
567        }),
568    )
569    .await;
570    fs.insert_tree(
571        path!("/root2"),
572        json!({
573            "dir_2": {
574                "file_1.java": "// File contents",
575            }
576        }),
577    )
578    .await;
579
580    // Test 1: Multiple worktrees with auto_fold_dirs = true
581    let project = Project::test(
582        fs.clone(),
583        [path!("/root1").as_ref(), path!("/root2").as_ref()],
584        cx,
585    )
586    .await;
587    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
588    let workspace = window
589        .read_with(cx, |mw, _| mw.workspace().clone())
590        .unwrap();
591    let cx = &mut VisualTestContext::from_window(window.into(), cx);
592    cx.update(|_, cx| {
593        let settings = *ProjectPanelSettings::get_global(cx);
594        ProjectPanelSettings::override_global(
595            ProjectPanelSettings {
596                auto_fold_dirs: true,
597                sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
598                ..settings
599            },
600            cx,
601        );
602    });
603    let panel = workspace.update_in(cx, ProjectPanel::new);
604    cx.run_until_parked();
605    assert_eq!(
606        visible_entries_as_strings(&panel, 0..10, cx),
607        &[
608            "v root1",
609            "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
610            "v root2",
611            "    > dir_2",
612        ]
613    );
614
615    toggle_expand_dir(
616        &panel,
617        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
618        cx,
619    );
620    assert_eq!(
621        visible_entries_as_strings(&panel, 0..10, cx),
622        &[
623            "v root1",
624            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
625            "        > nested_dir_4/nested_dir_5",
626            "          file_a.java",
627            "          file_b.java",
628            "          file_c.java",
629            "v root2",
630            "    > dir_2",
631        ]
632    );
633
634    toggle_expand_dir(
635        &panel,
636        "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
637        cx,
638    );
639    assert_eq!(
640        visible_entries_as_strings(&panel, 0..10, cx),
641        &[
642            "v root1",
643            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
644            "        v nested_dir_4/nested_dir_5  <== selected",
645            "              file_d.java",
646            "          file_a.java",
647            "          file_b.java",
648            "          file_c.java",
649            "v root2",
650            "    > dir_2",
651        ]
652    );
653    toggle_expand_dir(&panel, "root2/dir_2", cx);
654    assert_eq!(
655        visible_entries_as_strings(&panel, 0..10, cx),
656        &[
657            "v root1",
658            "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
659            "        v nested_dir_4/nested_dir_5",
660            "              file_d.java",
661            "          file_a.java",
662            "          file_b.java",
663            "          file_c.java",
664            "v root2",
665            "    v dir_2  <== selected",
666            "          file_1.java",
667        ]
668    );
669
670    // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
671    {
672        let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
673        let window =
674            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
675        let workspace = window
676            .read_with(cx, |mw, _| mw.workspace().clone())
677            .unwrap();
678        let cx = &mut VisualTestContext::from_window(window.into(), cx);
679        cx.update(|_, cx| {
680            let settings = *ProjectPanelSettings::get_global(cx);
681            ProjectPanelSettings::override_global(
682                ProjectPanelSettings {
683                    auto_fold_dirs: true,
684                    hide_root: true,
685                    ..settings
686                },
687                cx,
688            );
689        });
690        let panel = workspace.update_in(cx, ProjectPanel::new);
691        cx.run_until_parked();
692        assert_eq!(
693            visible_entries_as_strings(&panel, 0..10, cx),
694            &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
695            "Single worktree with hide_root=true should hide root and show auto-folded paths"
696        );
697
698        toggle_expand_dir(
699            &panel,
700            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
701            cx,
702        );
703        assert_eq!(
704            visible_entries_as_strings(&panel, 0..10, cx),
705            &[
706                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
707                "    > nested_dir_4/nested_dir_5",
708                "      file_a.java",
709                "      file_b.java",
710                "      file_c.java",
711            ],
712            "Expanded auto-folded path with hidden root should show contents without root prefix"
713        );
714
715        toggle_expand_dir(
716            &panel,
717            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
718            cx,
719        );
720        assert_eq!(
721            visible_entries_as_strings(&panel, 0..10, cx),
722            &[
723                "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
724                "    v nested_dir_4/nested_dir_5  <== selected",
725                "          file_d.java",
726                "      file_a.java",
727                "      file_b.java",
728                "      file_c.java",
729            ],
730            "Nested expansion with hidden root should maintain proper indentation"
731        );
732    }
733}
734
735#[gpui::test(iterations = 30)]
736async fn test_editing_files(cx: &mut gpui::TestAppContext) {
737    init_test(cx);
738
739    let fs = FakeFs::new(cx.executor());
740    fs.insert_tree(
741        "/root1",
742        json!({
743            ".dockerignore": "",
744            ".git": {
745                "HEAD": "",
746            },
747            "a": {
748                "0": { "q": "", "r": "", "s": "" },
749                "1": { "t": "", "u": "" },
750                "2": { "v": "", "w": "", "x": "", "y": "" },
751            },
752            "b": {
753                "3": { "Q": "" },
754                "4": { "R": "", "S": "", "T": "", "U": "" },
755            },
756            "C": {
757                "5": {},
758                "6": { "V": "", "W": "" },
759                "7": { "X": "" },
760                "8": { "Y": {}, "Z": "" }
761            }
762        }),
763    )
764    .await;
765    fs.insert_tree(
766        "/root2",
767        json!({
768            "d": {
769                "9": ""
770            },
771            "e": {}
772        }),
773    )
774    .await;
775
776    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
777    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
778    let workspace = window
779        .read_with(cx, |mw, _| mw.workspace().clone())
780        .unwrap();
781    let cx = &mut VisualTestContext::from_window(window.into(), cx);
782    let panel = workspace.update_in(cx, |workspace, window, cx| {
783        let panel = ProjectPanel::new(workspace, window, cx);
784        workspace.add_panel(panel.clone(), window, cx);
785        panel
786    });
787    cx.run_until_parked();
788
789    select_path(&panel, "root1", cx);
790    assert_eq!(
791        visible_entries_as_strings(&panel, 0..10, cx),
792        &[
793            "v root1  <== selected",
794            "    > .git",
795            "    > a",
796            "    > b",
797            "    > C",
798            "      .dockerignore",
799            "v root2",
800            "    > d",
801            "    > e",
802        ]
803    );
804
805    // Add a file with the root folder selected. The filename editor is placed
806    // before the first file in the root folder.
807    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
808    cx.run_until_parked();
809    panel.update_in(cx, |panel, window, cx| {
810        assert!(panel.filename_editor.read(cx).is_focused(window));
811    });
812    assert_eq!(
813        visible_entries_as_strings(&panel, 0..10, cx),
814        &[
815            "v root1",
816            "    > .git",
817            "    > a",
818            "    > b",
819            "    > C",
820            "      [EDITOR: '']  <== selected",
821            "      .dockerignore",
822            "v root2",
823            "    > d",
824            "    > e",
825        ]
826    );
827
828    let confirm = panel.update_in(cx, |panel, window, cx| {
829        panel.filename_editor.update(cx, |editor, cx| {
830            editor.set_text("the-new-filename", window, cx)
831        });
832        panel.confirm_edit(true, window, cx).unwrap()
833    });
834    assert_eq!(
835        visible_entries_as_strings(&panel, 0..10, cx),
836        &[
837            "v root1",
838            "    > .git",
839            "    > a",
840            "    > b",
841            "    > C",
842            "      [PROCESSING: 'the-new-filename']  <== selected",
843            "      .dockerignore",
844            "v root2",
845            "    > d",
846            "    > e",
847        ]
848    );
849
850    confirm.await.unwrap();
851    cx.run_until_parked();
852    assert_eq!(
853        visible_entries_as_strings(&panel, 0..10, cx),
854        &[
855            "v root1",
856            "    > .git",
857            "    > a",
858            "    > b",
859            "    > C",
860            "      .dockerignore",
861            "      the-new-filename  <== selected  <== marked",
862            "v root2",
863            "    > d",
864            "    > e",
865        ]
866    );
867
868    select_path(&panel, "root1/b", cx);
869    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
870    cx.run_until_parked();
871    assert_eq!(
872        visible_entries_as_strings(&panel, 0..10, cx),
873        &[
874            "v root1",
875            "    > .git",
876            "    > a",
877            "    v b",
878            "        > 3",
879            "        > 4",
880            "          [EDITOR: '']  <== selected",
881            "    > C",
882            "      .dockerignore",
883            "      the-new-filename",
884        ]
885    );
886
887    panel
888        .update_in(cx, |panel, window, cx| {
889            panel.filename_editor.update(cx, |editor, cx| {
890                editor.set_text("another-filename.txt", window, cx)
891            });
892            panel.confirm_edit(true, window, cx).unwrap()
893        })
894        .await
895        .unwrap();
896    cx.run_until_parked();
897    assert_eq!(
898        visible_entries_as_strings(&panel, 0..10, cx),
899        &[
900            "v root1",
901            "    > .git",
902            "    > a",
903            "    v b",
904            "        > 3",
905            "        > 4",
906            "          another-filename.txt  <== selected  <== marked",
907            "    > C",
908            "      .dockerignore",
909            "      the-new-filename",
910        ]
911    );
912
913    select_path(&panel, "root1/b/another-filename.txt", cx);
914    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
915    assert_eq!(
916        visible_entries_as_strings(&panel, 0..10, cx),
917        &[
918            "v root1",
919            "    > .git",
920            "    > a",
921            "    v b",
922            "        > 3",
923            "        > 4",
924            "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
925            "    > C",
926            "      .dockerignore",
927            "      the-new-filename",
928        ]
929    );
930
931    let confirm = panel.update_in(cx, |panel, window, cx| {
932        panel.filename_editor.update(cx, |editor, cx| {
933            let file_name_selections = editor
934                .selections
935                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
936            assert_eq!(
937                file_name_selections.len(),
938                1,
939                "File editing should have a single selection, but got: {file_name_selections:?}"
940            );
941            let file_name_selection = &file_name_selections[0];
942            assert_eq!(
943                file_name_selection.start,
944                MultiBufferOffset(0),
945                "Should select the file name from the start"
946            );
947            assert_eq!(
948                file_name_selection.end,
949                MultiBufferOffset("another-filename".len()),
950                "Should not select file extension"
951            );
952
953            editor.set_text("a-different-filename.tar.gz", window, cx)
954        });
955        panel.confirm_edit(true, window, cx).unwrap()
956    });
957    assert_eq!(
958        visible_entries_as_strings(&panel, 0..10, cx),
959        &[
960            "v root1",
961            "    > .git",
962            "    > a",
963            "    v b",
964            "        > 3",
965            "        > 4",
966            "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
967            "    > C",
968            "      .dockerignore",
969            "      the-new-filename",
970        ]
971    );
972
973    confirm.await.unwrap();
974    cx.run_until_parked();
975    assert_eq!(
976        visible_entries_as_strings(&panel, 0..10, cx),
977        &[
978            "v root1",
979            "    > .git",
980            "    > a",
981            "    v b",
982            "        > 3",
983            "        > 4",
984            "          a-different-filename.tar.gz  <== selected",
985            "    > C",
986            "      .dockerignore",
987            "      the-new-filename",
988        ]
989    );
990
991    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
992    assert_eq!(
993        visible_entries_as_strings(&panel, 0..10, cx),
994        &[
995            "v root1",
996            "    > .git",
997            "    > a",
998            "    v b",
999            "        > 3",
1000            "        > 4",
1001            "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
1002            "    > C",
1003            "      .dockerignore",
1004            "      the-new-filename",
1005        ]
1006    );
1007
1008    panel.update_in(cx, |panel, window, cx| {
1009            panel.filename_editor.update(cx, |editor, cx| {
1010                let file_name_selections = editor.selections.all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1011                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1012                let file_name_selection = &file_name_selections[0];
1013                assert_eq!(file_name_selection.start, MultiBufferOffset(0), "Should select the file name from the start");
1014                assert_eq!(file_name_selection.end, MultiBufferOffset("a-different-filename.tar".len()), "Should not select file extension, but still may select anything up to the last dot..");
1015
1016            });
1017            panel.cancel(&menu::Cancel, window, cx)
1018        });
1019    cx.run_until_parked();
1020    panel.update_in(cx, |panel, window, cx| {
1021        panel.new_directory(&NewDirectory, window, cx)
1022    });
1023    cx.run_until_parked();
1024    assert_eq!(
1025        visible_entries_as_strings(&panel, 0..10, cx),
1026        &[
1027            "v root1",
1028            "    > .git",
1029            "    > a",
1030            "    v b",
1031            "        > [EDITOR: '']  <== selected",
1032            "        > 3",
1033            "        > 4",
1034            "          a-different-filename.tar.gz",
1035            "    > C",
1036            "      .dockerignore",
1037        ]
1038    );
1039
1040    let confirm = panel.update_in(cx, |panel, window, cx| {
1041        panel
1042            .filename_editor
1043            .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
1044        panel.confirm_edit(true, window, cx).unwrap()
1045    });
1046    panel.update_in(cx, |panel, window, cx| {
1047        panel.select_next(&Default::default(), window, cx)
1048    });
1049    assert_eq!(
1050        visible_entries_as_strings(&panel, 0..10, cx),
1051        &[
1052            "v root1",
1053            "    > .git",
1054            "    > a",
1055            "    v b",
1056            "        > [PROCESSING: 'new-dir']",
1057            "        > 3  <== selected",
1058            "        > 4",
1059            "          a-different-filename.tar.gz",
1060            "    > C",
1061            "      .dockerignore",
1062        ]
1063    );
1064
1065    confirm.await.unwrap();
1066    cx.run_until_parked();
1067    assert_eq!(
1068        visible_entries_as_strings(&panel, 0..10, cx),
1069        &[
1070            "v root1",
1071            "    > .git",
1072            "    > a",
1073            "    v b",
1074            "        > 3  <== selected",
1075            "        > 4",
1076            "        > new-dir",
1077            "          a-different-filename.tar.gz",
1078            "    > C",
1079            "      .dockerignore",
1080        ]
1081    );
1082
1083    panel.update_in(cx, |panel, window, cx| {
1084        panel.rename(&Default::default(), window, cx)
1085    });
1086    cx.run_until_parked();
1087    assert_eq!(
1088        visible_entries_as_strings(&panel, 0..10, cx),
1089        &[
1090            "v root1",
1091            "    > .git",
1092            "    > a",
1093            "    v b",
1094            "        > [EDITOR: '3']  <== selected",
1095            "        > 4",
1096            "        > new-dir",
1097            "          a-different-filename.tar.gz",
1098            "    > C",
1099            "      .dockerignore",
1100        ]
1101    );
1102
1103    // Dismiss the rename editor when it loses focus.
1104    workspace.update_in(cx, |_, window, _| window.blur());
1105    assert_eq!(
1106        visible_entries_as_strings(&panel, 0..10, cx),
1107        &[
1108            "v root1",
1109            "    > .git",
1110            "    > a",
1111            "    v b",
1112            "        > 3  <== selected",
1113            "        > 4",
1114            "        > new-dir",
1115            "          a-different-filename.tar.gz",
1116            "    > C",
1117            "      .dockerignore",
1118        ]
1119    );
1120
1121    // Test empty filename and filename with only whitespace
1122    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1123    cx.run_until_parked();
1124    assert_eq!(
1125        visible_entries_as_strings(&panel, 0..10, cx),
1126        &[
1127            "v root1",
1128            "    > .git",
1129            "    > a",
1130            "    v b",
1131            "        v 3",
1132            "              [EDITOR: '']  <== selected",
1133            "              Q",
1134            "        > 4",
1135            "        > new-dir",
1136            "          a-different-filename.tar.gz",
1137        ]
1138    );
1139    panel.update_in(cx, |panel, window, cx| {
1140        panel.filename_editor.update(cx, |editor, cx| {
1141            editor.set_text("", window, cx);
1142        });
1143        assert!(panel.confirm_edit(true, window, cx).is_none());
1144        panel.filename_editor.update(cx, |editor, cx| {
1145            editor.set_text("   ", window, cx);
1146        });
1147        assert!(panel.confirm_edit(true, window, cx).is_none());
1148        panel.cancel(&menu::Cancel, window, cx);
1149    });
1150    cx.run_until_parked();
1151    assert_eq!(
1152        visible_entries_as_strings(&panel, 0..10, cx),
1153        &[
1154            "v root1",
1155            "    > .git",
1156            "    > a",
1157            "    v b",
1158            "        v 3  <== selected",
1159            "              Q",
1160            "        > 4",
1161            "        > new-dir",
1162            "          a-different-filename.tar.gz",
1163            "    > C",
1164        ]
1165    );
1166}
1167
1168#[gpui::test]
1169async fn test_rename_folder_with_dot_selects_whole_name(cx: &mut gpui::TestAppContext) {
1170    init_test(cx);
1171
1172    let fs = FakeFs::new(cx.executor());
1173    fs.insert_tree(
1174        "/root1",
1175        json!({
1176            "my.folder": {},
1177            "archive.tar.gz": "",
1178        }),
1179    )
1180    .await;
1181
1182    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1183    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1184    let workspace = window
1185        .read_with(cx, |mw, _| mw.workspace().clone())
1186        .unwrap();
1187    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1188    let panel = workspace.update_in(cx, |workspace, window, cx| {
1189        let panel = ProjectPanel::new(workspace, window, cx);
1190        workspace.add_panel(panel.clone(), window, cx);
1191        panel
1192    });
1193    cx.run_until_parked();
1194
1195    // Renaming a folder whose name contains a dot should pre-select the whole
1196    // name. The dot belongs to the directory name; it is not a file extension.
1197    select_path(&panel, "root1/my.folder", cx);
1198    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
1199    panel.update_in(cx, |panel, window, cx| {
1200        panel.filename_editor.update(cx, |editor, cx| {
1201            let selections = editor
1202                .selections
1203                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1204            assert_eq!(selections.len(), 1);
1205            assert_eq!(selections[0].start, MultiBufferOffset(0));
1206            assert_eq!(
1207                selections[0].end,
1208                MultiBufferOffset("my.folder".len()),
1209                "Renaming a folder should select the whole name, including dots"
1210            );
1211        });
1212        panel.cancel(&Cancel, window, cx);
1213    });
1214    cx.run_until_parked();
1215
1216    // Files keep the existing behavior: the last extension stays unselected.
1217    select_path(&panel, "root1/archive.tar.gz", cx);
1218    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
1219    panel.update_in(cx, |panel, _, cx| {
1220        panel.filename_editor.update(cx, |editor, cx| {
1221            let selections = editor
1222                .selections
1223                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1224            assert_eq!(
1225                selections[0].end,
1226                MultiBufferOffset("archive.tar".len()),
1227                "Renaming a file should keep the last extension unselected"
1228            );
1229        });
1230    });
1231}
1232
1233#[gpui::test(iterations = 10)]
1234async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
1235    init_test(cx);
1236
1237    let fs = FakeFs::new(cx.executor());
1238    fs.insert_tree(
1239        "/root1",
1240        json!({
1241            ".dockerignore": "",
1242            ".git": {
1243                "HEAD": "",
1244            },
1245            "a": {
1246                "0": { "q": "", "r": "", "s": "" },
1247                "1": { "t": "", "u": "" },
1248                "2": { "v": "", "w": "", "x": "", "y": "" },
1249            },
1250            "b": {
1251                "3": { "Q": "" },
1252                "4": { "R": "", "S": "", "T": "", "U": "" },
1253            },
1254            "C": {
1255                "5": {},
1256                "6": { "V": "", "W": "" },
1257                "7": { "X": "" },
1258                "8": { "Y": {}, "Z": "" }
1259            }
1260        }),
1261    )
1262    .await;
1263    fs.insert_tree(
1264        "/root2",
1265        json!({
1266            "d": {
1267                "9": ""
1268            },
1269            "e": {}
1270        }),
1271    )
1272    .await;
1273
1274    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1275    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1276    let workspace = window
1277        .read_with(cx, |mw, _| mw.workspace().clone())
1278        .unwrap();
1279    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1280    let panel = workspace.update_in(cx, |workspace, window, cx| {
1281        let panel = ProjectPanel::new(workspace, window, cx);
1282        workspace.add_panel(panel.clone(), window, cx);
1283        panel
1284    });
1285    cx.run_until_parked();
1286
1287    select_path(&panel, "root1", cx);
1288    assert_eq!(
1289        visible_entries_as_strings(&panel, 0..10, cx),
1290        &[
1291            "v root1  <== selected",
1292            "    > .git",
1293            "    > a",
1294            "    > b",
1295            "    > C",
1296            "      .dockerignore",
1297            "v root2",
1298            "    > d",
1299            "    > e",
1300        ]
1301    );
1302
1303    // Add a file with the root folder selected. The filename editor is placed
1304    // before the first file in the root folder.
1305    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1306    cx.run_until_parked();
1307    panel.update_in(cx, |panel, window, cx| {
1308        assert!(panel.filename_editor.read(cx).is_focused(window));
1309    });
1310    cx.run_until_parked();
1311    assert_eq!(
1312        visible_entries_as_strings(&panel, 0..10, cx),
1313        &[
1314            "v root1",
1315            "    > .git",
1316            "    > a",
1317            "    > b",
1318            "    > C",
1319            "      [EDITOR: '']  <== selected",
1320            "      .dockerignore",
1321            "v root2",
1322            "    > d",
1323            "    > e",
1324        ]
1325    );
1326
1327    let confirm = panel.update_in(cx, |panel, window, cx| {
1328        panel.filename_editor.update(cx, |editor, cx| {
1329            editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
1330        });
1331        panel.confirm_edit(true, window, cx).unwrap()
1332    });
1333
1334    assert_eq!(
1335        visible_entries_as_strings(&panel, 0..10, cx),
1336        &[
1337            "v root1",
1338            "    > .git",
1339            "    > a",
1340            "    > b",
1341            "    > C",
1342            "      [PROCESSING: 'bdir1/dir2/the-new-filename']  <== selected",
1343            "      .dockerignore",
1344            "v root2",
1345            "    > d",
1346            "    > e",
1347        ]
1348    );
1349
1350    confirm.await.unwrap();
1351    cx.run_until_parked();
1352    assert_eq!(
1353        visible_entries_as_strings(&panel, 0..13, cx),
1354        &[
1355            "v root1",
1356            "    > .git",
1357            "    > a",
1358            "    > b",
1359            "    v bdir1",
1360            "        v dir2",
1361            "              the-new-filename  <== selected  <== marked",
1362            "    > C",
1363            "      .dockerignore",
1364            "v root2",
1365            "    > d",
1366            "    > e",
1367        ]
1368    );
1369}
1370
1371#[gpui::test]
1372async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
1373    init_test(cx);
1374
1375    let fs = FakeFs::new(cx.executor());
1376    fs.insert_tree(
1377        path!("/root1"),
1378        json!({
1379            ".dockerignore": "",
1380            ".git": {
1381                "HEAD": "",
1382            },
1383        }),
1384    )
1385    .await;
1386
1387    let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
1388    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1389    let workspace = window
1390        .read_with(cx, |mw, _| mw.workspace().clone())
1391        .unwrap();
1392    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1393    let panel = workspace.update_in(cx, |workspace, window, cx| {
1394        let panel = ProjectPanel::new(workspace, window, cx);
1395        workspace.add_panel(panel.clone(), window, cx);
1396        panel
1397    });
1398    cx.run_until_parked();
1399
1400    select_path(&panel, "root1", cx);
1401    assert_eq!(
1402        visible_entries_as_strings(&panel, 0..10, cx),
1403        &["v root1  <== selected", "    > .git", "      .dockerignore",]
1404    );
1405
1406    // Add a file with the root folder selected. The filename editor is placed
1407    // before the first file in the root folder.
1408    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1409    cx.run_until_parked();
1410    panel.update_in(cx, |panel, window, cx| {
1411        assert!(panel.filename_editor.read(cx).is_focused(window));
1412    });
1413    assert_eq!(
1414        visible_entries_as_strings(&panel, 0..10, cx),
1415        &[
1416            "v root1",
1417            "    > .git",
1418            "      [EDITOR: '']  <== selected",
1419            "      .dockerignore",
1420        ]
1421    );
1422
1423    let confirm = panel.update_in(cx, |panel, window, cx| {
1424        // If we want to create a subdirectory, there should be no prefix slash.
1425        panel
1426            .filename_editor
1427            .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
1428        panel.confirm_edit(true, window, cx).unwrap()
1429    });
1430
1431    assert_eq!(
1432        visible_entries_as_strings(&panel, 0..10, cx),
1433        &[
1434            "v root1",
1435            "    > .git",
1436            "      [PROCESSING: 'new_dir']  <== selected",
1437            "      .dockerignore",
1438        ]
1439    );
1440
1441    confirm.await.unwrap();
1442    cx.run_until_parked();
1443    assert_eq!(
1444        visible_entries_as_strings(&panel, 0..10, cx),
1445        &[
1446            "v root1",
1447            "    > .git",
1448            "    v new_dir  <== selected",
1449            "      .dockerignore",
1450        ]
1451    );
1452
1453    // Test filename with whitespace
1454    select_path(&panel, "root1", cx);
1455    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1456    let confirm = panel.update_in(cx, |panel, window, cx| {
1457        // If we want to create a subdirectory, there should be no prefix slash.
1458        panel
1459            .filename_editor
1460            .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
1461        panel.confirm_edit(true, window, cx).unwrap()
1462    });
1463    confirm.await.unwrap();
1464    cx.run_until_parked();
1465    assert_eq!(
1466        visible_entries_as_strings(&panel, 0..10, cx),
1467        &[
1468            "v root1",
1469            "    > .git",
1470            "    v new dir 2  <== selected",
1471            "    v new_dir",
1472            "      .dockerignore",
1473        ]
1474    );
1475
1476    // Test filename ends with "\"
1477    #[cfg(target_os = "windows")]
1478    {
1479        select_path(&panel, "root1", cx);
1480        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1481        let confirm = panel.update_in(cx, |panel, window, cx| {
1482            // If we want to create a subdirectory, there should be no prefix slash.
1483            panel
1484                .filename_editor
1485                .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
1486            panel.confirm_edit(true, window, cx).unwrap()
1487        });
1488        confirm.await.unwrap();
1489        cx.run_until_parked();
1490        assert_eq!(
1491            visible_entries_as_strings(&panel, 0..10, cx),
1492            &[
1493                "v root1",
1494                "    > .git",
1495                "    v new dir 2",
1496                "    v new_dir",
1497                "    v new_dir_3  <== selected",
1498                "      .dockerignore",
1499            ]
1500        );
1501    }
1502}
1503
1504#[gpui::test]
1505async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1506    init_test(cx);
1507
1508    let fs = FakeFs::new(cx.executor());
1509    fs.insert_tree(
1510        "/root1",
1511        json!({
1512            "one.two.txt": "",
1513            "one.txt": ""
1514        }),
1515    )
1516    .await;
1517
1518    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1519    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1520    let workspace = window
1521        .read_with(cx, |mw, _| mw.workspace().clone())
1522        .unwrap();
1523    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1524    let panel = workspace.update_in(cx, ProjectPanel::new);
1525    cx.run_until_parked();
1526
1527    panel.update_in(cx, |panel, window, cx| {
1528        panel.select_next(&Default::default(), window, cx);
1529        panel.select_next(&Default::default(), window, cx);
1530    });
1531
1532    assert_eq!(
1533        visible_entries_as_strings(&panel, 0..50, cx),
1534        &[
1535            //
1536            "v root1",
1537            "      one.txt  <== selected",
1538            "      one.two.txt",
1539        ]
1540    );
1541
1542    // Regression test - file name is created correctly when
1543    // the copied file's name contains multiple dots.
1544    panel.update_in(cx, |panel, window, cx| {
1545        panel.copy(&Default::default(), window, cx);
1546        panel.paste(&Default::default(), window, cx);
1547    });
1548    cx.executor().run_until_parked();
1549    panel.update_in(cx, |panel, window, cx| {
1550        assert!(panel.filename_editor.read(cx).is_focused(window));
1551    });
1552    assert_eq!(
1553        visible_entries_as_strings(&panel, 0..50, cx),
1554        &[
1555            //
1556            "v root1",
1557            "      one.txt",
1558            "      [EDITOR: 'one copy.txt']  <== selected  <== marked",
1559            "      one.two.txt",
1560        ]
1561    );
1562
1563    panel.update_in(cx, |panel, window, cx| {
1564        panel.filename_editor.update(cx, |editor, cx| {
1565            let file_name_selections = editor
1566                .selections
1567                .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1568            assert_eq!(
1569                file_name_selections.len(),
1570                1,
1571                "File editing should have a single selection, but got: {file_name_selections:?}"
1572            );
1573            let file_name_selection = &file_name_selections[0];
1574            assert_eq!(
1575                file_name_selection.start,
1576                MultiBufferOffset(0),
1577                "Should select from the beginning of the filename"
1578            );
1579            assert_eq!(
1580                file_name_selection.end,
1581                MultiBufferOffset("one copy".len()),
1582                "Should select the file name disambiguation until the extension"
1583            );
1584        });
1585        assert!(panel.confirm_edit(true, window, cx).is_none());
1586    });
1587
1588    panel.update_in(cx, |panel, window, cx| {
1589        panel.paste(&Default::default(), window, cx);
1590    });
1591    cx.executor().run_until_parked();
1592    panel.update_in(cx, |panel, window, cx| {
1593        assert!(panel.filename_editor.read(cx).is_focused(window));
1594    });
1595    assert_eq!(
1596        visible_entries_as_strings(&panel, 0..50, cx),
1597        &[
1598            //
1599            "v root1",
1600            "      one.txt",
1601            "      one copy.txt",
1602            "      [EDITOR: 'one copy 1.txt']  <== selected  <== marked",
1603            "      one.two.txt",
1604        ]
1605    );
1606
1607    panel.update_in(cx, |panel, window, cx| {
1608        assert!(panel.confirm_edit(true, window, cx).is_none())
1609    });
1610}
1611
1612#[gpui::test]
1613async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
1614    init_test(cx);
1615
1616    let fs = FakeFs::new(cx.executor());
1617    fs.insert_tree(
1618        "/root",
1619        json!({
1620            "one.txt": "",
1621            "two.txt": "",
1622            "a": {},
1623            "b": {}
1624        }),
1625    )
1626    .await;
1627
1628    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1629    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1630    let workspace = window
1631        .read_with(cx, |mw, _| mw.workspace().clone())
1632        .unwrap();
1633    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1634    let panel = workspace.update_in(cx, ProjectPanel::new);
1635    cx.run_until_parked();
1636
1637    select_path_with_mark(&panel, "root/one.txt", cx);
1638    select_path_with_mark(&panel, "root/two.txt", cx);
1639
1640    assert_eq!(
1641        visible_entries_as_strings(&panel, 0..50, cx),
1642        &[
1643            "v root",
1644            "    > a",
1645            "    > b",
1646            "      one.txt  <== marked",
1647            "      two.txt  <== selected  <== marked",
1648        ]
1649    );
1650
1651    panel.update_in(cx, |panel, window, cx| {
1652        panel.cut(&Default::default(), window, cx);
1653    });
1654
1655    select_path(&panel, "root/a", cx);
1656
1657    panel.update_in(cx, |panel, window, cx| {
1658        panel.paste(&Default::default(), window, cx);
1659        panel.update_visible_entries(None, false, false, window, cx);
1660    });
1661    cx.executor().run_until_parked();
1662
1663    assert_eq!(
1664        visible_entries_as_strings(&panel, 0..50, cx),
1665        &[
1666            "v root",
1667            "    v a",
1668            "          one.txt  <== marked",
1669            "          two.txt  <== selected  <== marked",
1670            "    > b",
1671        ],
1672        "Cut entries should be moved on first paste."
1673    );
1674
1675    panel.update_in(cx, |panel, window, cx| {
1676        panel.cancel(&menu::Cancel {}, window, cx)
1677    });
1678    cx.executor().run_until_parked();
1679
1680    select_path(&panel, "root/b", cx);
1681
1682    panel.update_in(cx, |panel, window, cx| {
1683        panel.paste(&Default::default(), window, cx);
1684    });
1685    cx.executor().run_until_parked();
1686
1687    assert_eq!(
1688        visible_entries_as_strings(&panel, 0..50, cx),
1689        &[
1690            "v root",
1691            "    v a",
1692            "          one.txt",
1693            "          two.txt",
1694            "    v b",
1695            "          one.txt",
1696            "          two.txt  <== selected",
1697        ],
1698        "Cut entries should only be copied for the second paste!"
1699    );
1700}
1701
1702#[gpui::test]
1703async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1704    init_test(cx);
1705
1706    let fs = FakeFs::new(cx.executor());
1707    fs.insert_tree(
1708        "/root1",
1709        json!({
1710            "one.txt": "",
1711            "two.txt": "",
1712            "three.txt": "",
1713            "a": {
1714                "0": { "q": "", "r": "", "s": "" },
1715                "1": { "t": "", "u": "" },
1716                "2": { "v": "", "w": "", "x": "", "y": "" },
1717            },
1718        }),
1719    )
1720    .await;
1721
1722    fs.insert_tree(
1723        "/root2",
1724        json!({
1725            "one.txt": "",
1726            "two.txt": "",
1727            "four.txt": "",
1728            "b": {
1729                "3": { "Q": "" },
1730                "4": { "R": "", "S": "", "T": "", "U": "" },
1731            },
1732        }),
1733    )
1734    .await;
1735
1736    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1737    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1738    let workspace = window
1739        .read_with(cx, |mw, _| mw.workspace().clone())
1740        .unwrap();
1741    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1742    let panel = workspace.update_in(cx, ProjectPanel::new);
1743    cx.run_until_parked();
1744
1745    select_path(&panel, "root1/three.txt", cx);
1746    panel.update_in(cx, |panel, window, cx| {
1747        panel.cut(&Default::default(), window, cx);
1748    });
1749
1750    select_path(&panel, "root2/one.txt", cx);
1751    panel.update_in(cx, |panel, window, cx| {
1752        panel.select_next(&Default::default(), window, cx);
1753        panel.paste(&Default::default(), window, cx);
1754    });
1755    cx.executor().run_until_parked();
1756    assert_eq!(
1757        visible_entries_as_strings(&panel, 0..50, cx),
1758        &[
1759            //
1760            "v root1",
1761            "    > a",
1762            "      one.txt",
1763            "      two.txt",
1764            "v root2",
1765            "    > b",
1766            "      four.txt",
1767            "      one.txt",
1768            "      three.txt  <== selected  <== marked",
1769            "      two.txt",
1770        ]
1771    );
1772
1773    select_path(&panel, "root1/a", cx);
1774    panel.update_in(cx, |panel, window, cx| {
1775        panel.cut(&Default::default(), window, cx);
1776    });
1777    select_path(&panel, "root2/two.txt", cx);
1778    panel.update_in(cx, |panel, window, cx| {
1779        panel.select_next(&Default::default(), window, cx);
1780        panel.paste(&Default::default(), window, cx);
1781    });
1782
1783    cx.executor().run_until_parked();
1784    assert_eq!(
1785        visible_entries_as_strings(&panel, 0..50, cx),
1786        &[
1787            //
1788            "v root1",
1789            "      one.txt",
1790            "      two.txt",
1791            "v root2",
1792            "    > a  <== selected",
1793            "    > b",
1794            "      four.txt",
1795            "      one.txt",
1796            "      three.txt  <== marked",
1797            "      two.txt",
1798        ]
1799    );
1800}
1801
1802#[gpui::test]
1803async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1804    init_test(cx);
1805
1806    let fs = FakeFs::new(cx.executor());
1807    fs.insert_tree(
1808        "/root1",
1809        json!({
1810            "one.txt": "",
1811            "two.txt": "",
1812            "three.txt": "",
1813            "a": {
1814                "0": { "q": "", "r": "", "s": "" },
1815                "1": { "t": "", "u": "" },
1816                "2": { "v": "", "w": "", "x": "", "y": "" },
1817            },
1818        }),
1819    )
1820    .await;
1821
1822    fs.insert_tree(
1823        "/root2",
1824        json!({
1825            "one.txt": "",
1826            "two.txt": "",
1827            "four.txt": "",
1828            "b": {
1829                "3": { "Q": "" },
1830                "4": { "R": "", "S": "", "T": "", "U": "" },
1831            },
1832        }),
1833    )
1834    .await;
1835
1836    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1837    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1838    let workspace = window
1839        .read_with(cx, |mw, _| mw.workspace().clone())
1840        .unwrap();
1841    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1842    let panel = workspace.update_in(cx, ProjectPanel::new);
1843    cx.run_until_parked();
1844
1845    select_path(&panel, "root1/three.txt", cx);
1846    panel.update_in(cx, |panel, window, cx| {
1847        panel.copy(&Default::default(), window, cx);
1848    });
1849
1850    select_path(&panel, "root2/one.txt", cx);
1851    panel.update_in(cx, |panel, window, cx| {
1852        panel.select_next(&Default::default(), window, cx);
1853        panel.paste(&Default::default(), window, cx);
1854    });
1855    cx.executor().run_until_parked();
1856    assert_eq!(
1857        visible_entries_as_strings(&panel, 0..50, cx),
1858        &[
1859            //
1860            "v root1",
1861            "    > a",
1862            "      one.txt",
1863            "      three.txt",
1864            "      two.txt",
1865            "v root2",
1866            "    > b",
1867            "      four.txt",
1868            "      one.txt",
1869            "      three.txt  <== selected  <== marked",
1870            "      two.txt",
1871        ]
1872    );
1873
1874    select_path(&panel, "root1/three.txt", cx);
1875    panel.update_in(cx, |panel, window, cx| {
1876        panel.copy(&Default::default(), window, cx);
1877    });
1878    select_path(&panel, "root2/two.txt", cx);
1879    panel.update_in(cx, |panel, window, cx| {
1880        panel.select_next(&Default::default(), window, cx);
1881        panel.paste(&Default::default(), window, cx);
1882    });
1883
1884    cx.executor().run_until_parked();
1885    assert_eq!(
1886        visible_entries_as_strings(&panel, 0..50, cx),
1887        &[
1888            //
1889            "v root1",
1890            "    > a",
1891            "      one.txt",
1892            "      three.txt",
1893            "      two.txt",
1894            "v root2",
1895            "    > b",
1896            "      four.txt",
1897            "      one.txt",
1898            "      three.txt",
1899            "      [EDITOR: 'three copy.txt']  <== selected  <== marked",
1900            "      two.txt",
1901        ]
1902    );
1903
1904    panel.update_in(cx, |panel, window, cx| {
1905        panel.cancel(&menu::Cancel {}, window, cx)
1906    });
1907    cx.executor().run_until_parked();
1908
1909    select_path(&panel, "root1/a", cx);
1910    panel.update_in(cx, |panel, window, cx| {
1911        panel.copy(&Default::default(), window, cx);
1912    });
1913    select_path(&panel, "root2/two.txt", cx);
1914    panel.update_in(cx, |panel, window, cx| {
1915        panel.select_next(&Default::default(), window, cx);
1916        panel.paste(&Default::default(), window, cx);
1917    });
1918
1919    cx.executor().run_until_parked();
1920    assert_eq!(
1921        visible_entries_as_strings(&panel, 0..50, cx),
1922        &[
1923            //
1924            "v root1",
1925            "    > a",
1926            "      one.txt",
1927            "      three.txt",
1928            "      two.txt",
1929            "v root2",
1930            "    > a  <== selected",
1931            "    > b",
1932            "      four.txt",
1933            "      one.txt",
1934            "      three.txt",
1935            "      three copy.txt",
1936            "      two.txt",
1937        ]
1938    );
1939}
1940
1941#[gpui::test]
1942async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1943    init_test(cx);
1944
1945    let fs = FakeFs::new(cx.executor());
1946    fs.insert_tree(
1947        "/root",
1948        json!({
1949            "a": {
1950                "one.txt": "",
1951                "two.txt": "",
1952                "inner_dir": {
1953                    "three.txt": "",
1954                    "four.txt": "",
1955                }
1956            },
1957            "b": {},
1958            "d.1.20": {
1959                "default.conf": "",
1960            }
1961        }),
1962    )
1963    .await;
1964
1965    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1966    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1967    let workspace = window
1968        .read_with(cx, |mw, _| mw.workspace().clone())
1969        .unwrap();
1970    let cx = &mut VisualTestContext::from_window(window.into(), cx);
1971    let panel = workspace.update_in(cx, ProjectPanel::new);
1972    cx.run_until_parked();
1973
1974    select_path(&panel, "root/a", cx);
1975    panel.update_in(cx, |panel, window, cx| {
1976        panel.copy(&Default::default(), window, cx);
1977        panel.select_next(&Default::default(), window, cx);
1978        panel.paste(&Default::default(), window, cx);
1979    });
1980    cx.executor().run_until_parked();
1981
1982    let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1983    assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1984
1985    let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1986    assert_ne!(
1987        pasted_dir_file, None,
1988        "Pasted directory file should have an entry"
1989    );
1990
1991    let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1992    assert_ne!(
1993        pasted_dir_inner_dir, None,
1994        "Directories inside pasted directory should have an entry"
1995    );
1996
1997    toggle_expand_dir(&panel, "root/b/a", cx);
1998    toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1999
2000    assert_eq!(
2001        visible_entries_as_strings(&panel, 0..50, cx),
2002        &[
2003            //
2004            "v root",
2005            "    > a",
2006            "    v b",
2007            "        v a",
2008            "            v inner_dir  <== selected",
2009            "                  four.txt",
2010            "                  three.txt",
2011            "              one.txt",
2012            "              two.txt",
2013            "    > d.1.20",
2014        ]
2015    );
2016
2017    select_path(&panel, "root", cx);
2018    panel.update_in(cx, |panel, window, cx| {
2019        panel.paste(&Default::default(), window, cx)
2020    });
2021    cx.executor().run_until_parked();
2022    assert_eq!(
2023        visible_entries_as_strings(&panel, 0..50, cx),
2024        &[
2025            //
2026            "v root",
2027            "    > a",
2028            "    > [EDITOR: 'a copy']  <== selected",
2029            "    v b",
2030            "        v a",
2031            "            v inner_dir",
2032            "                  four.txt",
2033            "                  three.txt",
2034            "              one.txt",
2035            "              two.txt",
2036            "    > d.1.20",
2037        ]
2038    );
2039
2040    let confirm = panel.update_in(cx, |panel, window, cx| {
2041        panel
2042            .filename_editor
2043            .update(cx, |editor, cx| editor.set_text("c", window, cx));
2044        panel.confirm_edit(true, window, cx).unwrap()
2045    });
2046    assert_eq!(
2047        visible_entries_as_strings(&panel, 0..50, cx),
2048        &[
2049            //
2050            "v root",
2051            "    > a",
2052            "    > [PROCESSING: 'c']  <== selected",
2053            "    v b",
2054            "        v a",
2055            "            v inner_dir",
2056            "                  four.txt",
2057            "                  three.txt",
2058            "              one.txt",
2059            "              two.txt",
2060            "    > d.1.20",
2061        ]
2062    );
2063
2064    confirm.await.unwrap();
2065
2066    panel.update_in(cx, |panel, window, cx| {
2067        panel.paste(&Default::default(), window, cx)
2068    });
2069    cx.executor().run_until_parked();
2070    assert_eq!(
2071        visible_entries_as_strings(&panel, 0..50, cx),
2072        &[
2073            //
2074            "v root",
2075            "    > a",
2076            "    v b",
2077            "        v a",
2078            "            v inner_dir",
2079            "                  four.txt",
2080            "                  three.txt",
2081            "              one.txt",
2082            "              two.txt",
2083            "    v c",
2084            "        > a  <== selected",
2085            "        > inner_dir",
2086            "          one.txt",
2087            "          two.txt",
2088            "    > d.1.20",
2089        ]
2090    );
2091
2092    select_path(&panel, "root/d.1.20", cx);
2093    panel.update_in(cx, |panel, window, cx| {
2094        panel.copy(&Default::default(), window, cx);
2095        panel.paste(&Default::default(), window, cx);
2096    });
2097    cx.executor().run_until_parked();
2098    assert_eq!(
2099        visible_entries_as_strings(&panel, 0..50, cx),
2100        &[
2101            //
2102            "v root",
2103            "    > a",
2104            "    v b",
2105            "        v a",
2106            "            v inner_dir",
2107            "                  four.txt",
2108            "                  three.txt",
2109            "              one.txt",
2110            "              two.txt",
2111            "    v c",
2112            "        > a",
2113            "        > inner_dir",
2114            "          one.txt",
2115            "          two.txt",
2116            "    v d.1.20",
2117            "          default.conf",
2118            "    > [EDITOR: 'd.1.20 copy']  <== selected",
2119        ],
2120        "Dotted directory names should not be split at the dot when disambiguating"
2121    );
2122}
2123
2124#[gpui::test]
2125async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
2126    init_test(cx);
2127
2128    let fs = FakeFs::new(cx.executor());
2129    fs.insert_tree(
2130        "/test",
2131        json!({
2132            "dir1": {
2133                "a.txt": "",
2134                "b.txt": "",
2135            },
2136            "dir2": {},
2137            "c.txt": "",
2138            "d.txt": "",
2139        }),
2140    )
2141    .await;
2142
2143    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
2144    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2145    let workspace = window
2146        .read_with(cx, |mw, _| mw.workspace().clone())
2147        .unwrap();
2148    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2149    let panel = workspace.update_in(cx, ProjectPanel::new);
2150    cx.run_until_parked();
2151
2152    toggle_expand_dir(&panel, "test/dir1", cx);
2153
2154    cx.simulate_modifiers_change(gpui::Modifiers {
2155        control: true,
2156        ..Default::default()
2157    });
2158
2159    select_path_with_mark(&panel, "test/dir1", cx);
2160    select_path_with_mark(&panel, "test/c.txt", cx);
2161
2162    assert_eq!(
2163        visible_entries_as_strings(&panel, 0..15, cx),
2164        &[
2165            "v test",
2166            "    v dir1  <== marked",
2167            "          a.txt",
2168            "          b.txt",
2169            "    > dir2",
2170            "      c.txt  <== selected  <== marked",
2171            "      d.txt",
2172        ],
2173        "Initial state before copying dir1 and c.txt"
2174    );
2175
2176    panel.update_in(cx, |panel, window, cx| {
2177        panel.copy(&Default::default(), window, cx);
2178    });
2179    select_path(&panel, "test/dir2", cx);
2180    panel.update_in(cx, |panel, window, cx| {
2181        panel.paste(&Default::default(), window, cx);
2182    });
2183    cx.executor().run_until_parked();
2184
2185    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
2186
2187    assert_eq!(
2188        visible_entries_as_strings(&panel, 0..15, cx),
2189        &[
2190            "v test",
2191            "    v dir1  <== marked",
2192            "          a.txt",
2193            "          b.txt",
2194            "    v dir2",
2195            "        v dir1  <== selected",
2196            "              a.txt",
2197            "              b.txt",
2198            "          c.txt",
2199            "      c.txt  <== marked",
2200            "      d.txt",
2201        ],
2202        "Should copy dir1 as well as c.txt into dir2"
2203    );
2204
2205    // Disambiguating multiple files should not open the rename editor.
2206    select_path(&panel, "test/dir2", cx);
2207    panel.update_in(cx, |panel, window, cx| {
2208        panel.paste(&Default::default(), window, cx);
2209    });
2210    cx.executor().run_until_parked();
2211
2212    assert_eq!(
2213        visible_entries_as_strings(&panel, 0..15, cx),
2214        &[
2215            "v test",
2216            "    v dir1  <== marked",
2217            "          a.txt",
2218            "          b.txt",
2219            "    v dir2",
2220            "        v dir1",
2221            "              a.txt",
2222            "              b.txt",
2223            "        > dir1 copy  <== selected",
2224            "          c.txt",
2225            "          c copy.txt",
2226            "      c.txt  <== marked",
2227            "      d.txt",
2228        ],
2229        "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
2230    );
2231}
2232
2233#[gpui::test]
2234async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
2235    init_test(cx);
2236
2237    let fs = FakeFs::new(cx.executor());
2238    fs.insert_tree(
2239        "/test",
2240        json!({
2241            "dir1": {
2242                "a.txt": "",
2243                "b.txt": "",
2244            },
2245            "dir2": {},
2246            "c.txt": "",
2247            "d.txt": "",
2248        }),
2249    )
2250    .await;
2251
2252    let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
2253    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2254    let workspace = window
2255        .read_with(cx, |mw, _| mw.workspace().clone())
2256        .unwrap();
2257    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2258    let panel = workspace.update_in(cx, ProjectPanel::new);
2259    cx.run_until_parked();
2260
2261    toggle_expand_dir(&panel, "test/dir1", cx);
2262
2263    cx.simulate_modifiers_change(gpui::Modifiers {
2264        control: true,
2265        ..Default::default()
2266    });
2267
2268    select_path_with_mark(&panel, "test/dir1/a.txt", cx);
2269    select_path_with_mark(&panel, "test/dir1", cx);
2270    select_path_with_mark(&panel, "test/c.txt", cx);
2271
2272    assert_eq!(
2273        visible_entries_as_strings(&panel, 0..15, cx),
2274        &[
2275            "v test",
2276            "    v dir1  <== marked",
2277            "          a.txt  <== marked",
2278            "          b.txt",
2279            "    > dir2",
2280            "      c.txt  <== selected  <== marked",
2281            "      d.txt",
2282        ],
2283        "Initial state before copying a.txt, dir1 and c.txt"
2284    );
2285
2286    panel.update_in(cx, |panel, window, cx| {
2287        panel.copy(&Default::default(), window, cx);
2288    });
2289    select_path(&panel, "test/dir2", cx);
2290    panel.update_in(cx, |panel, window, cx| {
2291        panel.paste(&Default::default(), window, cx);
2292    });
2293    cx.executor().run_until_parked();
2294
2295    toggle_expand_dir(&panel, "test/dir2/dir1", cx);
2296
2297    assert_eq!(
2298        visible_entries_as_strings(&panel, 0..20, cx),
2299        &[
2300            "v test",
2301            "    v dir1  <== marked",
2302            "          a.txt  <== marked",
2303            "          b.txt",
2304            "    v dir2",
2305            "        v dir1  <== selected",
2306            "              a.txt",
2307            "              b.txt",
2308            "          c.txt",
2309            "      c.txt  <== marked",
2310            "      d.txt",
2311        ],
2312        "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
2313    );
2314}
2315
2316#[gpui::test]
2317async fn test_paste_external_paths(cx: &mut gpui::TestAppContext) {
2318    init_test(cx);
2319    set_auto_open_settings(
2320        cx,
2321        ProjectPanelAutoOpenSettings {
2322            on_drop: Some(false),
2323            ..Default::default()
2324        },
2325    );
2326
2327    let fs = FakeFs::new(cx.executor());
2328    fs.insert_tree(
2329        path!("/root"),
2330        json!({
2331            "subdir": {}
2332        }),
2333    )
2334    .await;
2335
2336    fs.insert_tree(
2337        path!("/external"),
2338        json!({
2339            "new_file.rs": "fn main() {}"
2340        }),
2341    )
2342    .await;
2343
2344    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2345    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2346    let workspace = window
2347        .read_with(cx, |mw, _| mw.workspace().clone())
2348        .unwrap();
2349    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2350    let panel = workspace.update_in(cx, ProjectPanel::new);
2351    cx.run_until_parked();
2352
2353    cx.write_to_clipboard(ClipboardItem {
2354        entries: vec![GpuiClipboardEntry::ExternalPaths(ExternalPaths(
2355            smallvec::smallvec![PathBuf::from(path!("/external/new_file.rs"))],
2356        ))],
2357    });
2358
2359    select_path(&panel, "root/subdir", cx);
2360    panel.update_in(cx, |panel, window, cx| {
2361        panel.paste(&Default::default(), window, cx);
2362    });
2363    cx.executor().run_until_parked();
2364
2365    assert_eq!(
2366        visible_entries_as_strings(&panel, 0..50, cx),
2367        &[
2368            "v root",
2369            "    v subdir",
2370            "          new_file.rs  <== selected",
2371        ],
2372    );
2373}
2374
2375#[gpui::test]
2376async fn test_copy_and_cut_write_to_system_clipboard(cx: &mut gpui::TestAppContext) {
2377    init_test(cx);
2378
2379    let fs = FakeFs::new(cx.executor());
2380    fs.insert_tree(
2381        path!("/root"),
2382        json!({
2383            "file_a.txt": "",
2384            "file_b.txt": ""
2385        }),
2386    )
2387    .await;
2388
2389    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2390    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2391    let workspace = window
2392        .read_with(cx, |mw, _| mw.workspace().clone())
2393        .unwrap();
2394    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2395    let panel = workspace.update_in(cx, ProjectPanel::new);
2396    cx.run_until_parked();
2397
2398    select_path(&panel, "root/file_a.txt", cx);
2399    panel.update_in(cx, |panel, window, cx| {
2400        panel.copy(&Default::default(), window, cx);
2401    });
2402
2403    let clipboard = cx
2404        .read_from_clipboard()
2405        .expect("clipboard should have content after copy");
2406    let text = clipboard.text().expect("clipboard should contain text");
2407    assert!(
2408        text.contains("file_a.txt"),
2409        "System clipboard should contain the copied file path, got: {text}"
2410    );
2411
2412    select_path(&panel, "root/file_b.txt", cx);
2413    panel.update_in(cx, |panel, window, cx| {
2414        panel.cut(&Default::default(), window, cx);
2415    });
2416
2417    let clipboard = cx
2418        .read_from_clipboard()
2419        .expect("clipboard should have content after cut");
2420    let text = clipboard.text().expect("clipboard should contain text");
2421    assert!(
2422        text.contains("file_b.txt"),
2423        "System clipboard should contain the cut file path, got: {text}"
2424    );
2425}
2426
2427#[gpui::test]
2428async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2429    init_test_with_editor(cx);
2430
2431    let fs = FakeFs::new(cx.executor());
2432    fs.insert_tree(
2433        path!("/src"),
2434        json!({
2435            "test": {
2436                "first.rs": "// First Rust file",
2437                "second.rs": "// Second Rust file",
2438                "third.rs": "// Third Rust file",
2439            }
2440        }),
2441    )
2442    .await;
2443
2444    let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
2445    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2446    let workspace = window
2447        .read_with(cx, |mw, _| mw.workspace().clone())
2448        .unwrap();
2449    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2450    let panel = workspace.update_in(cx, ProjectPanel::new);
2451    cx.run_until_parked();
2452
2453    toggle_expand_dir(&panel, "src/test", cx);
2454    select_path(&panel, "src/test/first.rs", cx);
2455    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2456    cx.executor().run_until_parked();
2457    assert_eq!(
2458        visible_entries_as_strings(&panel, 0..10, cx),
2459        &[
2460            "v src",
2461            "    v test",
2462            "          first.rs  <== selected  <== marked",
2463            "          second.rs",
2464            "          third.rs"
2465        ]
2466    );
2467    ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2468
2469    submit_deletion(&panel, cx);
2470    assert_eq!(
2471        visible_entries_as_strings(&panel, 0..10, cx),
2472        &[
2473            "v src",
2474            "    v test",
2475            "          second.rs  <== selected",
2476            "          third.rs"
2477        ],
2478        "Project panel should have no deleted file, no other file is selected in it"
2479    );
2480    ensure_no_open_items_and_panes(&workspace, cx);
2481
2482    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2483    cx.executor().run_until_parked();
2484    assert_eq!(
2485        visible_entries_as_strings(&panel, 0..10, cx),
2486        &[
2487            "v src",
2488            "    v test",
2489            "          second.rs  <== selected  <== marked",
2490            "          third.rs"
2491        ]
2492    );
2493    ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2494
2495    workspace.update_in(cx, |workspace, window, cx| {
2496        let active_items = workspace
2497            .panes()
2498            .iter()
2499            .filter_map(|pane| pane.read(cx).active_item())
2500            .collect::<Vec<_>>();
2501        assert_eq!(active_items.len(), 1);
2502        let open_editor = active_items
2503            .into_iter()
2504            .next()
2505            .unwrap()
2506            .downcast::<Editor>()
2507            .expect("Open item should be an editor");
2508        open_editor.update(cx, |editor, cx| {
2509            editor.set_text("Another text!", window, cx)
2510        });
2511    });
2512    submit_deletion_skipping_prompt(&panel, cx);
2513    assert_eq!(
2514        visible_entries_as_strings(&panel, 0..10, cx),
2515        &["v src", "    v test", "          third.rs  <== selected"],
2516        "Project panel should have no deleted file, with one last file remaining"
2517    );
2518    ensure_no_open_items_and_panes(&workspace, cx);
2519}
2520
2521#[gpui::test]
2522async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) {
2523    init_test_with_editor(cx);
2524    set_auto_open_settings(
2525        cx,
2526        ProjectPanelAutoOpenSettings {
2527            on_create: Some(true),
2528            ..Default::default()
2529        },
2530    );
2531
2532    let fs = FakeFs::new(cx.executor());
2533    fs.insert_tree(path!("/root"), json!({})).await;
2534
2535    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2536    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2537    let workspace = window
2538        .read_with(cx, |mw, _| mw.workspace().clone())
2539        .unwrap();
2540    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2541    let panel = workspace.update_in(cx, ProjectPanel::new);
2542    cx.run_until_parked();
2543
2544    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2545    cx.run_until_parked();
2546    panel
2547        .update_in(cx, |panel, window, cx| {
2548            panel.filename_editor.update(cx, |editor, cx| {
2549                editor.set_text("auto-open.rs", window, cx);
2550            });
2551            panel.confirm_edit(true, window, cx).unwrap()
2552        })
2553        .await
2554        .unwrap();
2555    cx.run_until_parked();
2556
2557    ensure_single_file_is_opened(&workspace, "auto-open.rs", cx);
2558}
2559
2560#[gpui::test]
2561async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) {
2562    init_test_with_editor(cx);
2563    set_auto_open_settings(
2564        cx,
2565        ProjectPanelAutoOpenSettings {
2566            on_create: Some(false),
2567            ..Default::default()
2568        },
2569    );
2570
2571    let fs = FakeFs::new(cx.executor());
2572    fs.insert_tree(path!("/root"), json!({})).await;
2573
2574    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2575    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2576    let workspace = window
2577        .read_with(cx, |mw, _| mw.workspace().clone())
2578        .unwrap();
2579    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2580    let panel = workspace.update_in(cx, ProjectPanel::new);
2581    cx.run_until_parked();
2582
2583    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2584    cx.run_until_parked();
2585    panel
2586        .update_in(cx, |panel, window, cx| {
2587            panel.filename_editor.update(cx, |editor, cx| {
2588                editor.set_text("manual-open.rs", window, cx);
2589            });
2590            panel.confirm_edit(true, window, cx).unwrap()
2591        })
2592        .await
2593        .unwrap();
2594    cx.run_until_parked();
2595
2596    ensure_no_open_items_and_panes(&workspace, cx);
2597}
2598
2599#[gpui::test]
2600async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) {
2601    init_test_with_editor(cx);
2602    set_auto_open_settings(
2603        cx,
2604        ProjectPanelAutoOpenSettings {
2605            on_paste: Some(true),
2606            ..Default::default()
2607        },
2608    );
2609
2610    let fs = FakeFs::new(cx.executor());
2611    fs.insert_tree(
2612        path!("/root"),
2613        json!({
2614            "src": {
2615                "original.rs": ""
2616            },
2617            "target": {}
2618        }),
2619    )
2620    .await;
2621
2622    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2623    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2624    let workspace = window
2625        .read_with(cx, |mw, _| mw.workspace().clone())
2626        .unwrap();
2627    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2628    let panel = workspace.update_in(cx, ProjectPanel::new);
2629    cx.run_until_parked();
2630
2631    toggle_expand_dir(&panel, "root/src", cx);
2632    toggle_expand_dir(&panel, "root/target", cx);
2633
2634    select_path(&panel, "root/src/original.rs", cx);
2635    panel.update_in(cx, |panel, window, cx| {
2636        panel.copy(&Default::default(), window, cx);
2637    });
2638
2639    select_path(&panel, "root/target", cx);
2640    panel.update_in(cx, |panel, window, cx| {
2641        panel.paste(&Default::default(), window, cx);
2642    });
2643    cx.executor().run_until_parked();
2644
2645    ensure_single_file_is_opened(&workspace, "target/original.rs", cx);
2646}
2647
2648#[gpui::test]
2649async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) {
2650    init_test_with_editor(cx);
2651    set_auto_open_settings(
2652        cx,
2653        ProjectPanelAutoOpenSettings {
2654            on_paste: Some(false),
2655            ..Default::default()
2656        },
2657    );
2658
2659    let fs = FakeFs::new(cx.executor());
2660    fs.insert_tree(
2661        path!("/root"),
2662        json!({
2663            "src": {
2664                "original.rs": ""
2665            },
2666            "target": {}
2667        }),
2668    )
2669    .await;
2670
2671    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2672    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2673    let workspace = window
2674        .read_with(cx, |mw, _| mw.workspace().clone())
2675        .unwrap();
2676    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2677    let panel = workspace.update_in(cx, ProjectPanel::new);
2678    cx.run_until_parked();
2679
2680    toggle_expand_dir(&panel, "root/src", cx);
2681    toggle_expand_dir(&panel, "root/target", cx);
2682
2683    select_path(&panel, "root/src/original.rs", cx);
2684    panel.update_in(cx, |panel, window, cx| {
2685        panel.copy(&Default::default(), window, cx);
2686    });
2687
2688    select_path(&panel, "root/target", cx);
2689    panel.update_in(cx, |panel, window, cx| {
2690        panel.paste(&Default::default(), window, cx);
2691    });
2692    cx.executor().run_until_parked();
2693
2694    ensure_no_open_items_and_panes(&workspace, cx);
2695    assert!(
2696        find_project_entry(&panel, "root/target/original.rs", cx).is_some(),
2697        "Pasted entry should exist even when auto-open is disabled"
2698    );
2699}
2700
2701#[gpui::test]
2702async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) {
2703    init_test_with_editor(cx);
2704    set_auto_open_settings(
2705        cx,
2706        ProjectPanelAutoOpenSettings {
2707            on_drop: Some(true),
2708            ..Default::default()
2709        },
2710    );
2711
2712    let fs = FakeFs::new(cx.executor());
2713    fs.insert_tree(path!("/root"), json!({})).await;
2714
2715    let temp_dir = tempfile::tempdir().unwrap();
2716    let external_path = temp_dir.path().join("dropped.rs");
2717    std::fs::write(&external_path, "// dropped").unwrap();
2718    fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2719        .await;
2720
2721    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2722    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2723    let workspace = window
2724        .read_with(cx, |mw, _| mw.workspace().clone())
2725        .unwrap();
2726    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2727    let panel = workspace.update_in(cx, ProjectPanel::new);
2728    cx.run_until_parked();
2729
2730    let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2731    panel.update_in(cx, |panel, window, cx| {
2732        panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2733    });
2734    cx.executor().run_until_parked();
2735
2736    ensure_single_file_is_opened(&workspace, "dropped.rs", cx);
2737}
2738
2739#[gpui::test]
2740async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) {
2741    init_test_with_editor(cx);
2742    set_auto_open_settings(
2743        cx,
2744        ProjectPanelAutoOpenSettings {
2745            on_drop: Some(false),
2746            ..Default::default()
2747        },
2748    );
2749
2750    let fs = FakeFs::new(cx.executor());
2751    fs.insert_tree(path!("/root"), json!({})).await;
2752
2753    let temp_dir = tempfile::tempdir().unwrap();
2754    let external_path = temp_dir.path().join("manual.rs");
2755    std::fs::write(&external_path, "// dropped").unwrap();
2756    fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2757        .await;
2758
2759    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2760    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2761    let workspace = window
2762        .read_with(cx, |mw, _| mw.workspace().clone())
2763        .unwrap();
2764    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2765    let panel = workspace.update_in(cx, ProjectPanel::new);
2766    cx.run_until_parked();
2767
2768    let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2769    panel.update_in(cx, |panel, window, cx| {
2770        panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2771    });
2772    cx.executor().run_until_parked();
2773
2774    ensure_no_open_items_and_panes(&workspace, cx);
2775    assert!(
2776        find_project_entry(&panel, "root/manual.rs", cx).is_some(),
2777        "Dropped entry should exist even when auto-open is disabled"
2778    );
2779}
2780
2781#[gpui::test]
2782async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2783    init_test_with_editor(cx);
2784
2785    let fs = FakeFs::new(cx.executor());
2786    fs.insert_tree(
2787        "/src",
2788        json!({
2789            "test": {
2790                "first.rs": "// First Rust file",
2791                "second.rs": "// Second Rust file",
2792                "third.rs": "// Third Rust file",
2793            }
2794        }),
2795    )
2796    .await;
2797
2798    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2799    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2800    let workspace = window
2801        .read_with(cx, |mw, _| mw.workspace().clone())
2802        .unwrap();
2803    let cx = &mut VisualTestContext::from_window(window.into(), cx);
2804    let panel = workspace.update_in(cx, |workspace, window, cx| {
2805        let panel = ProjectPanel::new(workspace, window, cx);
2806        workspace.add_panel(panel.clone(), window, cx);
2807        panel
2808    });
2809    cx.run_until_parked();
2810
2811    select_path(&panel, "src", cx);
2812    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2813    cx.executor().run_until_parked();
2814    assert_eq!(
2815        visible_entries_as_strings(&panel, 0..10, cx),
2816        &[
2817            //
2818            "v src  <== selected",
2819            "    > test"
2820        ]
2821    );
2822    panel.update_in(cx, |panel, window, cx| {
2823        panel.new_directory(&NewDirectory, window, cx)
2824    });
2825    cx.run_until_parked();
2826    panel.update_in(cx, |panel, window, cx| {
2827        assert!(panel.filename_editor.read(cx).is_focused(window));
2828    });
2829    cx.executor().run_until_parked();
2830    assert_eq!(
2831        visible_entries_as_strings(&panel, 0..10, cx),
2832        &[
2833            //
2834            "v src",
2835            "    > [EDITOR: '']  <== selected",
2836            "    > test"
2837        ]
2838    );
2839    panel.update_in(cx, |panel, window, cx| {
2840        panel
2841            .filename_editor
2842            .update(cx, |editor, cx| editor.set_text("test", window, cx));
2843        assert!(
2844            panel.confirm_edit(true, window, cx).is_none(),
2845            "Should not allow to confirm on conflicting new directory name"
2846        );
2847    });
2848    cx.executor().run_until_parked();
2849    panel.update_in(cx, |panel, window, cx| {
2850        assert!(
2851            panel.state.edit_state.is_some(),
2852            "Edit state should not be None after conflicting new directory name"
2853        );
2854        panel.cancel(&menu::Cancel, window, cx);
2855    });
2856    cx.run_until_parked();
2857    assert_eq!(
2858        visible_entries_as_strings(&panel, 0..10, cx),
2859        &[
2860            //
2861            "v src  <== selected",
2862            "    > test"
2863        ],
2864        "File list should be unchanged after failed folder create confirmation"
2865    );
2866
2867    select_path(&panel, "src/test", cx);
2868    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2869    cx.executor().run_until_parked();
2870    assert_eq!(
2871        visible_entries_as_strings(&panel, 0..10, cx),
2872        &[
2873            //
2874            "v src",
2875            "    > test  <== selected"
2876        ]
2877    );
2878    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2879    cx.run_until_parked();
2880    panel.update_in(cx, |panel, window, cx| {
2881        assert!(panel.filename_editor.read(cx).is_focused(window));
2882    });
2883    assert_eq!(
2884        visible_entries_as_strings(&panel, 0..10, cx),
2885        &[
2886            "v src",
2887            "    v test",
2888            "          [EDITOR: '']  <== selected",
2889            "          first.rs",
2890            "          second.rs",
2891            "          third.rs"
2892        ]
2893    );
2894    panel.update_in(cx, |panel, window, cx| {
2895        panel
2896            .filename_editor
2897            .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2898        assert!(
2899            panel.confirm_edit(true, window, cx).is_none(),
2900            "Should not allow to confirm on conflicting new file name"
2901        );
2902    });
2903    cx.executor().run_until_parked();
2904    panel.update_in(cx, |panel, window, cx| {
2905        assert!(
2906            panel.state.edit_state.is_some(),
2907            "Edit state should not be None after conflicting new file name"
2908        );
2909        panel.cancel(&menu::Cancel, window, cx);
2910    });
2911    cx.run_until_parked();
2912    assert_eq!(
2913        visible_entries_as_strings(&panel, 0..10, cx),
2914        &[
2915            "v src",
2916            "    v test  <== selected",
2917            "          first.rs",
2918            "          second.rs",
2919            "          third.rs"
2920        ],
2921        "File list should be unchanged after failed file create confirmation"
2922    );
2923
2924    select_path(&panel, "src/test/first.rs", cx);
2925    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2926    cx.executor().run_until_parked();
2927    assert_eq!(
2928        visible_entries_as_strings(&panel, 0..10, cx),
2929        &[
2930            "v src",
2931            "    v test",
2932            "          first.rs  <== selected",
2933            "          second.rs",
2934            "          third.rs"
2935        ],
2936    );
2937    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2938    cx.executor().run_until_parked();
2939    panel.update_in(cx, |panel, window, cx| {
2940        assert!(panel.filename_editor.read(cx).is_focused(window));
2941    });
2942    assert_eq!(
2943        visible_entries_as_strings(&panel, 0..10, cx),
2944        &[
2945            "v src",
2946            "    v test",
2947            "          [EDITOR: 'first.rs']  <== selected",
2948            "          second.rs",
2949            "          third.rs"
2950        ]
2951    );
2952    panel.update_in(cx, |panel, window, cx| {
2953        panel
2954            .filename_editor
2955            .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2956        assert!(
2957            panel.confirm_edit(true, window, cx).is_none(),
2958            "Should not allow to confirm on conflicting file rename"
2959        )
2960    });
2961    cx.executor().run_until_parked();
2962    panel.update_in(cx, |panel, window, cx| {
2963        assert!(
2964            panel.state.edit_state.is_some(),
2965            "Edit state should not be None after conflicting file rename"
2966        );
2967        panel.cancel(&menu::Cancel, window, cx);
2968    });
2969    cx.executor().run_until_parked();
2970    assert_eq!(
2971        visible_entries_as_strings(&panel, 0..10, cx),
2972        &[
2973            "v src",
2974            "    v test",
2975            "          first.rs  <== selected",
2976            "          second.rs",
2977            "          third.rs"
2978        ],
2979        "File list should be unchanged after failed rename confirmation"
2980    );
2981}
2982
2983// NOTE: This test is skipped on Windows, because on Windows,
2984// when it triggers the lsp store it converts `/src/test/first copy.txt` into an uri
2985// but it fails with message `"/src\\test\\first copy.txt" is not parseable as an URI`
2986#[gpui::test]
2987#[cfg_attr(target_os = "windows", ignore)]
2988async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppContext) {
2989    init_test_with_editor(cx);
2990
2991    let fs = FakeFs::new(cx.executor());
2992    fs.insert_tree(
2993        "/src",
2994        json!({
2995            "test": {
2996                "first.txt": "// First Txt file",
2997                "second.txt": "// Second Txt file",
2998                "third.txt": "// Third Txt file",
2999            }
3000        }),
3001    )
3002    .await;
3003
3004    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3005    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3006    let workspace = window
3007        .read_with(cx, |mw, _| mw.workspace().clone())
3008        .unwrap();
3009    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3010    let panel = workspace.update_in(cx, |workspace, window, cx| {
3011        let panel = ProjectPanel::new(workspace, window, cx);
3012        workspace.add_panel(panel.clone(), window, cx);
3013        panel
3014    });
3015    cx.run_until_parked();
3016
3017    select_path(&panel, "src", cx);
3018    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3019    cx.executor().run_until_parked();
3020    assert_eq!(
3021        visible_entries_as_strings(&panel, 0..10, cx),
3022        &[
3023            //
3024            "v src  <== selected",
3025            "    > test"
3026        ]
3027    );
3028    panel.update_in(cx, |panel, window, cx| {
3029        panel.new_directory(&NewDirectory, window, cx)
3030    });
3031    cx.run_until_parked();
3032    panel.update_in(cx, |panel, window, cx| {
3033        assert!(panel.filename_editor.read(cx).is_focused(window));
3034    });
3035    cx.executor().run_until_parked();
3036    assert_eq!(
3037        visible_entries_as_strings(&panel, 0..10, cx),
3038        &[
3039            //
3040            "v src",
3041            "    > [EDITOR: '']  <== selected",
3042            "    > test"
3043        ]
3044    );
3045    panel.update_in(cx, |panel, window, cx| {
3046        panel
3047            .filename_editor
3048            .update(cx, |editor, cx| editor.set_text("test", window, cx));
3049        assert!(
3050            panel.confirm_edit(true, window, cx).is_none(),
3051            "Should not allow to confirm on conflicting new directory name"
3052        );
3053    });
3054    cx.executor().run_until_parked();
3055    panel.update_in(cx, |panel, window, cx| {
3056        assert!(
3057            panel.state.edit_state.is_some(),
3058            "Edit state should not be None after conflicting new directory name"
3059        );
3060        panel.cancel(&menu::Cancel, window, cx);
3061    });
3062    cx.run_until_parked();
3063    assert_eq!(
3064        visible_entries_as_strings(&panel, 0..10, cx),
3065        &[
3066            //
3067            "v src  <== selected",
3068            "    > test"
3069        ],
3070        "File list should be unchanged after failed folder create confirmation"
3071    );
3072
3073    select_path(&panel, "src/test", cx);
3074    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3075    cx.executor().run_until_parked();
3076    assert_eq!(
3077        visible_entries_as_strings(&panel, 0..10, cx),
3078        &[
3079            //
3080            "v src",
3081            "    > test  <== selected"
3082        ]
3083    );
3084    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3085    cx.run_until_parked();
3086    panel.update_in(cx, |panel, window, cx| {
3087        assert!(panel.filename_editor.read(cx).is_focused(window));
3088    });
3089    assert_eq!(
3090        visible_entries_as_strings(&panel, 0..10, cx),
3091        &[
3092            "v src",
3093            "    v test",
3094            "          [EDITOR: '']  <== selected",
3095            "          first.txt",
3096            "          second.txt",
3097            "          third.txt"
3098        ]
3099    );
3100    panel.update_in(cx, |panel, window, cx| {
3101        panel
3102            .filename_editor
3103            .update(cx, |editor, cx| editor.set_text("first.txt", window, cx));
3104        assert!(
3105            panel.confirm_edit(true, window, cx).is_none(),
3106            "Should not allow to confirm on conflicting new file name"
3107        );
3108    });
3109    cx.executor().run_until_parked();
3110    panel.update_in(cx, |panel, window, cx| {
3111        assert!(
3112            panel.state.edit_state.is_some(),
3113            "Edit state should not be None after conflicting new file name"
3114        );
3115        panel.cancel(&menu::Cancel, window, cx);
3116    });
3117    cx.run_until_parked();
3118    assert_eq!(
3119        visible_entries_as_strings(&panel, 0..10, cx),
3120        &[
3121            "v src",
3122            "    v test  <== selected",
3123            "          first.txt",
3124            "          second.txt",
3125            "          third.txt"
3126        ],
3127        "File list should be unchanged after failed file create confirmation"
3128    );
3129
3130    select_path(&panel, "src/test/first.txt", cx);
3131    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3132    cx.executor().run_until_parked();
3133    assert_eq!(
3134        visible_entries_as_strings(&panel, 0..10, cx),
3135        &[
3136            "v src",
3137            "    v test",
3138            "          first.txt  <== selected",
3139            "          second.txt",
3140            "          third.txt"
3141        ],
3142    );
3143    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3144    cx.executor().run_until_parked();
3145    panel.update_in(cx, |panel, window, cx| {
3146        assert!(panel.filename_editor.read(cx).is_focused(window));
3147    });
3148    assert_eq!(
3149        visible_entries_as_strings(&panel, 0..10, cx),
3150        &[
3151            "v src",
3152            "    v test",
3153            "          [EDITOR: 'first.txt']  <== selected",
3154            "          second.txt",
3155            "          third.txt"
3156        ]
3157    );
3158    panel.update_in(cx, |panel, window, cx| {
3159        panel
3160            .filename_editor
3161            .update(cx, |editor, cx| editor.set_text("second.txt", window, cx));
3162        assert!(
3163            panel.confirm_edit(true, window, cx).is_none(),
3164            "Should not allow to confirm on conflicting file rename"
3165        )
3166    });
3167    cx.executor().run_until_parked();
3168    panel.update_in(cx, |panel, window, cx| {
3169        assert!(
3170            panel.state.edit_state.is_some(),
3171            "Edit state should not be None after conflicting file rename"
3172        );
3173        panel.cancel(&menu::Cancel, window, cx);
3174    });
3175    cx.executor().run_until_parked();
3176    assert_eq!(
3177        visible_entries_as_strings(&panel, 0..10, cx),
3178        &[
3179            "v src",
3180            "    v test",
3181            "          first.txt  <== selected",
3182            "          second.txt",
3183            "          third.txt"
3184        ],
3185        "File list should be unchanged after failed rename confirmation"
3186    );
3187    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3188    cx.executor().run_until_parked();
3189    // Try to duplicate and check history
3190    panel.update_in(cx, |panel, window, cx| {
3191        panel.duplicate(&Duplicate, window, cx)
3192    });
3193    cx.executor().run_until_parked();
3194
3195    assert_eq!(
3196        visible_entries_as_strings(&panel, 0..10, cx),
3197        &[
3198            "v src",
3199            "    v test",
3200            "          first.txt",
3201            "          [EDITOR: 'first copy.txt']  <== selected  <== marked",
3202            "          second.txt",
3203            "          third.txt"
3204        ],
3205    );
3206
3207    let confirm = panel.update_in(cx, |panel, window, cx| {
3208        panel
3209            .filename_editor
3210            .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
3211        panel.confirm_edit(true, window, cx).unwrap()
3212    });
3213    confirm.await.unwrap();
3214    cx.executor().run_until_parked();
3215
3216    assert_eq!(
3217        visible_entries_as_strings(&panel, 0..10, cx),
3218        &[
3219            "v src",
3220            "    v test",
3221            "          first.txt",
3222            "          fourth.txt  <== selected",
3223            "          second.txt",
3224            "          third.txt"
3225        ],
3226        "File list should be different after rename confirmation"
3227    );
3228
3229    panel.update_in(cx, |panel, window, cx| {
3230        panel.update_visible_entries(None, false, false, window, cx);
3231    });
3232    cx.executor().run_until_parked();
3233
3234    select_path(&panel, "src/test/first.txt", cx);
3235    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3236    cx.executor().run_until_parked();
3237
3238    workspace.read_with(cx, |this, cx| {
3239        assert!(
3240            this.recent_navigation_history_iter(cx)
3241                .any(|(project_path, abs_path)| {
3242                    project_path.path == Arc::from(rel_path("test/fourth.txt"))
3243                        && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
3244                })
3245        );
3246    });
3247}
3248
3249// NOTE: This test is skipped on Windows, because on Windows,
3250// when it triggers the lsp store it converts `/src/test/first.txt` into an uri
3251// but it fails with message `"/src\\test\\first.txt" is not parseable as an URI`
3252#[gpui::test]
3253#[cfg_attr(target_os = "windows", ignore)]
3254async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) {
3255    init_test_with_editor(cx);
3256
3257    let fs = FakeFs::new(cx.executor());
3258    fs.insert_tree(
3259        "/src",
3260        json!({
3261            "test": {
3262                "first.txt": "// First Txt file",
3263                "second.txt": "// Second Txt file",
3264                "third.txt": "// Third Txt file",
3265            }
3266        }),
3267    )
3268    .await;
3269
3270    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3271    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3272    let workspace = window
3273        .read_with(cx, |mw, _| mw.workspace().clone())
3274        .unwrap();
3275    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3276    let panel = workspace.update_in(cx, |workspace, window, cx| {
3277        let panel = ProjectPanel::new(workspace, window, cx);
3278        workspace.add_panel(panel.clone(), window, cx);
3279        panel
3280    });
3281    cx.run_until_parked();
3282
3283    select_path(&panel, "src", cx);
3284    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3285    cx.executor().run_until_parked();
3286    assert_eq!(
3287        visible_entries_as_strings(&panel, 0..10, cx),
3288        &[
3289            //
3290            "v src  <== selected",
3291            "    > test"
3292        ]
3293    );
3294
3295    select_path(&panel, "src/test", cx);
3296    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3297    cx.executor().run_until_parked();
3298    assert_eq!(
3299        visible_entries_as_strings(&panel, 0..10, cx),
3300        &[
3301            //
3302            "v src",
3303            "    > test  <== selected"
3304        ]
3305    );
3306    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3307    cx.run_until_parked();
3308    panel.update_in(cx, |panel, window, cx| {
3309        assert!(panel.filename_editor.read(cx).is_focused(window));
3310    });
3311
3312    select_path(&panel, "src/test/first.txt", cx);
3313    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3314    cx.executor().run_until_parked();
3315
3316    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3317    cx.executor().run_until_parked();
3318
3319    assert_eq!(
3320        visible_entries_as_strings(&panel, 0..10, cx),
3321        &[
3322            "v src",
3323            "    v test",
3324            "          [EDITOR: 'first.txt']  <== selected  <== marked",
3325            "          second.txt",
3326            "          third.txt"
3327        ],
3328    );
3329
3330    let confirm = panel.update_in(cx, |panel, window, cx| {
3331        panel
3332            .filename_editor
3333            .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
3334        panel.confirm_edit(true, window, cx).unwrap()
3335    });
3336    confirm.await.unwrap();
3337    cx.executor().run_until_parked();
3338
3339    assert_eq!(
3340        visible_entries_as_strings(&panel, 0..10, cx),
3341        &[
3342            "v src",
3343            "    v test",
3344            "          fourth.txt  <== selected",
3345            "          second.txt",
3346            "          third.txt"
3347        ],
3348        "File list should be different after rename confirmation"
3349    );
3350
3351    panel.update_in(cx, |panel, window, cx| {
3352        panel.update_visible_entries(None, false, false, window, cx);
3353    });
3354    cx.executor().run_until_parked();
3355
3356    select_path(&panel, "src/test/second.txt", cx);
3357    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3358    cx.executor().run_until_parked();
3359
3360    workspace.read_with(cx, |this, cx| {
3361        assert!(
3362            this.recent_navigation_history_iter(cx)
3363                .any(|(project_path, abs_path)| {
3364                    project_path.path == Arc::from(rel_path("test/fourth.txt"))
3365                        && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
3366                })
3367        );
3368    });
3369}
3370
3371#[gpui::test]
3372async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
3373    init_test_with_editor(cx);
3374
3375    let fs = FakeFs::new(cx.executor());
3376    fs.insert_tree(
3377        path!("/root"),
3378        json!({
3379            "tree1": {
3380                ".git": {},
3381                "dir1": {
3382                    "modified1.txt": "1",
3383                    "unmodified1.txt": "1",
3384                    "modified2.txt": "1",
3385                },
3386                "dir2": {
3387                    "modified3.txt": "1",
3388                    "unmodified2.txt": "1",
3389                },
3390                "modified4.txt": "1",
3391                "unmodified3.txt": "1",
3392            },
3393            "tree2": {
3394                ".git": {},
3395                "dir3": {
3396                    "modified5.txt": "1",
3397                    "unmodified4.txt": "1",
3398                },
3399                "modified6.txt": "1",
3400                "unmodified5.txt": "1",
3401            }
3402        }),
3403    )
3404    .await;
3405
3406    // Mark files as git modified
3407    fs.set_head_and_index_for_repo(
3408        path!("/root/tree1/.git").as_ref(),
3409        &[
3410            ("dir1/modified1.txt", "modified".into()),
3411            ("dir1/modified2.txt", "modified".into()),
3412            ("modified4.txt", "modified".into()),
3413            ("dir2/modified3.txt", "modified".into()),
3414        ],
3415    );
3416    fs.set_head_and_index_for_repo(
3417        path!("/root/tree2/.git").as_ref(),
3418        &[
3419            ("dir3/modified5.txt", "modified".into()),
3420            ("modified6.txt", "modified".into()),
3421        ],
3422    );
3423
3424    let project = Project::test(
3425        fs.clone(),
3426        [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
3427        cx,
3428    )
3429    .await;
3430
3431    let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
3432        let mut worktrees = project.worktrees(cx);
3433        let worktree1 = worktrees.next().unwrap();
3434        let worktree2 = worktrees.next().unwrap();
3435        (
3436            worktree1.read(cx).as_local().unwrap().scan_complete(),
3437            worktree2.read(cx).as_local().unwrap().scan_complete(),
3438        )
3439    });
3440    scan1_complete.await;
3441    scan2_complete.await;
3442    cx.run_until_parked();
3443
3444    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3445    let workspace = window
3446        .read_with(cx, |mw, _| mw.workspace().clone())
3447        .unwrap();
3448    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3449    let panel = workspace.update_in(cx, ProjectPanel::new);
3450    cx.run_until_parked();
3451
3452    // Check initial state
3453    assert_eq!(
3454        visible_entries_as_strings(&panel, 0..15, cx),
3455        &[
3456            "v tree1",
3457            "    > .git",
3458            "    > dir1",
3459            "    > dir2",
3460            "      modified4.txt",
3461            "      unmodified3.txt",
3462            "v tree2",
3463            "    > .git",
3464            "    > dir3",
3465            "      modified6.txt",
3466            "      unmodified5.txt"
3467        ],
3468    );
3469
3470    // Test selecting next modified entry
3471    panel.update_in(cx, |panel, window, cx| {
3472        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3473    });
3474    cx.run_until_parked();
3475
3476    assert_eq!(
3477        visible_entries_as_strings(&panel, 0..6, cx),
3478        &[
3479            "v tree1",
3480            "    > .git",
3481            "    v dir1",
3482            "          modified1.txt  <== selected",
3483            "          modified2.txt",
3484            "          unmodified1.txt",
3485        ],
3486    );
3487
3488    panel.update_in(cx, |panel, window, cx| {
3489        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3490    });
3491    cx.run_until_parked();
3492
3493    assert_eq!(
3494        visible_entries_as_strings(&panel, 0..6, cx),
3495        &[
3496            "v tree1",
3497            "    > .git",
3498            "    v dir1",
3499            "          modified1.txt",
3500            "          modified2.txt  <== selected",
3501            "          unmodified1.txt",
3502        ],
3503    );
3504
3505    panel.update_in(cx, |panel, window, cx| {
3506        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3507    });
3508    cx.run_until_parked();
3509
3510    assert_eq!(
3511        visible_entries_as_strings(&panel, 6..9, cx),
3512        &[
3513            "    v dir2",
3514            "          modified3.txt  <== selected",
3515            "          unmodified2.txt",
3516        ],
3517    );
3518
3519    panel.update_in(cx, |panel, window, cx| {
3520        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3521    });
3522    cx.run_until_parked();
3523
3524    assert_eq!(
3525        visible_entries_as_strings(&panel, 9..11, cx),
3526        &["      modified4.txt  <== selected", "      unmodified3.txt",],
3527    );
3528
3529    panel.update_in(cx, |panel, window, cx| {
3530        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3531    });
3532    cx.run_until_parked();
3533
3534    assert_eq!(
3535        visible_entries_as_strings(&panel, 13..16, cx),
3536        &[
3537            "    v dir3",
3538            "          modified5.txt  <== selected",
3539            "          unmodified4.txt",
3540        ],
3541    );
3542
3543    panel.update_in(cx, |panel, window, cx| {
3544        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3545    });
3546    cx.run_until_parked();
3547
3548    assert_eq!(
3549        visible_entries_as_strings(&panel, 16..18, cx),
3550        &["      modified6.txt  <== selected", "      unmodified5.txt",],
3551    );
3552
3553    // Wraps around to first modified file
3554    panel.update_in(cx, |panel, window, cx| {
3555        panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3556    });
3557    cx.run_until_parked();
3558
3559    assert_eq!(
3560        visible_entries_as_strings(&panel, 0..18, cx),
3561        &[
3562            "v tree1",
3563            "    > .git",
3564            "    v dir1",
3565            "          modified1.txt  <== selected",
3566            "          modified2.txt",
3567            "          unmodified1.txt",
3568            "    v dir2",
3569            "          modified3.txt",
3570            "          unmodified2.txt",
3571            "      modified4.txt",
3572            "      unmodified3.txt",
3573            "v tree2",
3574            "    > .git",
3575            "    v dir3",
3576            "          modified5.txt",
3577            "          unmodified4.txt",
3578            "      modified6.txt",
3579            "      unmodified5.txt",
3580        ],
3581    );
3582
3583    // Wraps around again to last modified file
3584    panel.update_in(cx, |panel, window, cx| {
3585        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3586    });
3587    cx.run_until_parked();
3588
3589    assert_eq!(
3590        visible_entries_as_strings(&panel, 16..18, cx),
3591        &["      modified6.txt  <== selected", "      unmodified5.txt",],
3592    );
3593
3594    panel.update_in(cx, |panel, window, cx| {
3595        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3596    });
3597    cx.run_until_parked();
3598
3599    assert_eq!(
3600        visible_entries_as_strings(&panel, 13..16, cx),
3601        &[
3602            "    v dir3",
3603            "          modified5.txt  <== selected",
3604            "          unmodified4.txt",
3605        ],
3606    );
3607
3608    panel.update_in(cx, |panel, window, cx| {
3609        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3610    });
3611    cx.run_until_parked();
3612
3613    assert_eq!(
3614        visible_entries_as_strings(&panel, 9..11, cx),
3615        &["      modified4.txt  <== selected", "      unmodified3.txt",],
3616    );
3617
3618    panel.update_in(cx, |panel, window, cx| {
3619        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3620    });
3621    cx.run_until_parked();
3622
3623    assert_eq!(
3624        visible_entries_as_strings(&panel, 6..9, cx),
3625        &[
3626            "    v dir2",
3627            "          modified3.txt  <== selected",
3628            "          unmodified2.txt",
3629        ],
3630    );
3631
3632    panel.update_in(cx, |panel, window, cx| {
3633        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3634    });
3635    cx.run_until_parked();
3636
3637    assert_eq!(
3638        visible_entries_as_strings(&panel, 0..6, cx),
3639        &[
3640            "v tree1",
3641            "    > .git",
3642            "    v dir1",
3643            "          modified1.txt",
3644            "          modified2.txt  <== selected",
3645            "          unmodified1.txt",
3646        ],
3647    );
3648
3649    panel.update_in(cx, |panel, window, cx| {
3650        panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3651    });
3652    cx.run_until_parked();
3653
3654    assert_eq!(
3655        visible_entries_as_strings(&panel, 0..6, cx),
3656        &[
3657            "v tree1",
3658            "    > .git",
3659            "    v dir1",
3660            "          modified1.txt  <== selected",
3661            "          modified2.txt",
3662            "          unmodified1.txt",
3663        ],
3664    );
3665}
3666
3667#[gpui::test]
3668async fn test_select_directory(cx: &mut gpui::TestAppContext) {
3669    init_test_with_editor(cx);
3670
3671    let fs = FakeFs::new(cx.executor());
3672    fs.insert_tree(
3673        "/project_root",
3674        json!({
3675            "dir_1": {
3676                "nested_dir": {
3677                    "file_a.py": "# File contents",
3678                }
3679            },
3680            "file_1.py": "# File contents",
3681            "dir_2": {
3682
3683            },
3684            "dir_3": {
3685
3686            },
3687            "file_2.py": "# File contents",
3688            "dir_4": {
3689
3690            },
3691        }),
3692    )
3693    .await;
3694
3695    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3696    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3697    let workspace = window
3698        .read_with(cx, |mw, _| mw.workspace().clone())
3699        .unwrap();
3700    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3701    let panel = workspace.update_in(cx, ProjectPanel::new);
3702    cx.run_until_parked();
3703
3704    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3705    cx.executor().run_until_parked();
3706    select_path(&panel, "project_root/dir_1", cx);
3707    cx.executor().run_until_parked();
3708    assert_eq!(
3709        visible_entries_as_strings(&panel, 0..10, cx),
3710        &[
3711            "v project_root",
3712            "    > dir_1  <== selected",
3713            "    > dir_2",
3714            "    > dir_3",
3715            "    > dir_4",
3716            "      file_1.py",
3717            "      file_2.py",
3718        ]
3719    );
3720    panel.update_in(cx, |panel, window, cx| {
3721        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3722    });
3723
3724    assert_eq!(
3725        visible_entries_as_strings(&panel, 0..10, cx),
3726        &[
3727            "v project_root  <== selected",
3728            "    > dir_1",
3729            "    > dir_2",
3730            "    > dir_3",
3731            "    > dir_4",
3732            "      file_1.py",
3733            "      file_2.py",
3734        ]
3735    );
3736
3737    panel.update_in(cx, |panel, window, cx| {
3738        panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3739    });
3740
3741    assert_eq!(
3742        visible_entries_as_strings(&panel, 0..10, cx),
3743        &[
3744            "v project_root",
3745            "    > dir_1",
3746            "    > dir_2",
3747            "    > dir_3",
3748            "    > dir_4  <== selected",
3749            "      file_1.py",
3750            "      file_2.py",
3751        ]
3752    );
3753
3754    panel.update_in(cx, |panel, window, cx| {
3755        panel.select_next_directory(&SelectNextDirectory, window, cx)
3756    });
3757
3758    assert_eq!(
3759        visible_entries_as_strings(&panel, 0..10, cx),
3760        &[
3761            "v project_root  <== selected",
3762            "    > dir_1",
3763            "    > dir_2",
3764            "    > dir_3",
3765            "    > dir_4",
3766            "      file_1.py",
3767            "      file_2.py",
3768        ]
3769    );
3770}
3771
3772#[gpui::test]
3773async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
3774    init_test_with_editor(cx);
3775
3776    let fs = FakeFs::new(cx.executor());
3777    fs.insert_tree(
3778        "/project_root",
3779        json!({
3780            "dir_1": {
3781                "nested_dir": {
3782                    "file_a.py": "# File contents",
3783                }
3784            },
3785            "file_1.py": "# File contents",
3786            "file_2.py": "# File contents",
3787            "zdir_2": {
3788                "nested_dir2": {
3789                    "file_b.py": "# File contents",
3790                }
3791            },
3792        }),
3793    )
3794    .await;
3795
3796    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3797    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3798    let workspace = window
3799        .read_with(cx, |mw, _| mw.workspace().clone())
3800        .unwrap();
3801    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3802    let panel = workspace.update_in(cx, ProjectPanel::new);
3803    cx.run_until_parked();
3804
3805    assert_eq!(
3806        visible_entries_as_strings(&panel, 0..10, cx),
3807        &[
3808            "v project_root",
3809            "    > dir_1",
3810            "    > zdir_2",
3811            "      file_1.py",
3812            "      file_2.py",
3813        ]
3814    );
3815    panel.update_in(cx, |panel, window, cx| {
3816        panel.select_first(&SelectFirst, window, cx)
3817    });
3818
3819    assert_eq!(
3820        visible_entries_as_strings(&panel, 0..10, cx),
3821        &[
3822            "v project_root  <== selected",
3823            "    > dir_1",
3824            "    > zdir_2",
3825            "      file_1.py",
3826            "      file_2.py",
3827        ]
3828    );
3829
3830    panel.update_in(cx, |panel, window, cx| {
3831        panel.select_last(&SelectLast, window, cx)
3832    });
3833
3834    assert_eq!(
3835        visible_entries_as_strings(&panel, 0..10, cx),
3836        &[
3837            "v project_root",
3838            "    > dir_1",
3839            "    > zdir_2",
3840            "      file_1.py",
3841            "      file_2.py  <== selected",
3842        ]
3843    );
3844
3845    cx.update(|_, cx| {
3846        let settings = *ProjectPanelSettings::get_global(cx);
3847        ProjectPanelSettings::override_global(
3848            ProjectPanelSettings {
3849                hide_root: true,
3850                ..settings
3851            },
3852            cx,
3853        );
3854    });
3855
3856    let panel = workspace.update_in(cx, ProjectPanel::new);
3857    cx.run_until_parked();
3858
3859    #[rustfmt::skip]
3860    assert_eq!(
3861        visible_entries_as_strings(&panel, 0..10, cx),
3862        &[
3863            "> dir_1",
3864            "> zdir_2",
3865            "  file_1.py",
3866            "  file_2.py",
3867        ],
3868        "With hide_root=true, root should be hidden"
3869    );
3870
3871    panel.update_in(cx, |panel, window, cx| {
3872        panel.select_first(&SelectFirst, window, cx)
3873    });
3874
3875    assert_eq!(
3876        visible_entries_as_strings(&panel, 0..10, cx),
3877        &[
3878            "> dir_1  <== selected",
3879            "> zdir_2",
3880            "  file_1.py",
3881            "  file_2.py",
3882        ],
3883        "With hide_root=true, first entry should be dir_1, not the hidden root"
3884    );
3885}
3886
3887#[gpui::test]
3888async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3889    init_test_with_editor(cx);
3890
3891    let fs = FakeFs::new(cx.executor());
3892    fs.insert_tree(
3893        "/project_root",
3894        json!({
3895            "dir_1": {
3896                "nested_dir": {
3897                    "file_a.py": "# File contents",
3898                }
3899            },
3900            "file_1.py": "# File contents",
3901        }),
3902    )
3903    .await;
3904
3905    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3906    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3907    let workspace = window
3908        .read_with(cx, |mw, _| mw.workspace().clone())
3909        .unwrap();
3910    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3911    let panel = workspace.update_in(cx, ProjectPanel::new);
3912    cx.run_until_parked();
3913
3914    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3915    cx.executor().run_until_parked();
3916    select_path(&panel, "project_root/dir_1", cx);
3917    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3918    select_path(&panel, "project_root/dir_1/nested_dir", cx);
3919    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3920    panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3921    cx.executor().run_until_parked();
3922    assert_eq!(
3923        visible_entries_as_strings(&panel, 0..10, cx),
3924        &[
3925            "v project_root",
3926            "    v dir_1",
3927            "        > nested_dir  <== selected",
3928            "      file_1.py",
3929        ]
3930    );
3931}
3932
3933#[gpui::test]
3934async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3935    init_test_with_editor(cx);
3936
3937    let fs = FakeFs::new(cx.executor());
3938    fs.insert_tree(
3939        "/project_root",
3940        json!({
3941            "dir_1": {
3942                "nested_dir": {
3943                    "file_a.py": "# File contents",
3944                    "file_b.py": "# File contents",
3945                    "file_c.py": "# File contents",
3946                },
3947                "file_1.py": "# File contents",
3948                "file_2.py": "# File contents",
3949                "file_3.py": "# File contents",
3950            },
3951            "dir_2": {
3952                "file_1.py": "# File contents",
3953                "file_2.py": "# File contents",
3954                "file_3.py": "# File contents",
3955            }
3956        }),
3957    )
3958    .await;
3959
3960    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3961    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3962    let workspace = window
3963        .read_with(cx, |mw, _| mw.workspace().clone())
3964        .unwrap();
3965    let cx = &mut VisualTestContext::from_window(window.into(), cx);
3966    let panel = workspace.update_in(cx, ProjectPanel::new);
3967    cx.run_until_parked();
3968
3969    panel.update_in(cx, |panel, window, cx| {
3970        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3971    });
3972    cx.executor().run_until_parked();
3973    assert_eq!(
3974        visible_entries_as_strings(&panel, 0..10, cx),
3975        &["v project_root", "    > dir_1", "    > dir_2",]
3976    );
3977
3978    // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3979    toggle_expand_dir(&panel, "project_root/dir_1", cx);
3980    cx.executor().run_until_parked();
3981    assert_eq!(
3982        visible_entries_as_strings(&panel, 0..10, cx),
3983        &[
3984            "v project_root",
3985            "    v dir_1  <== selected",
3986            "        > nested_dir",
3987            "          file_1.py",
3988            "          file_2.py",
3989            "          file_3.py",
3990            "    > dir_2",
3991        ]
3992    );
3993}
3994
3995#[gpui::test]
3996async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
3997    init_test_with_editor(cx);
3998
3999    let fs = FakeFs::new(cx.executor());
4000    let worktree_content = json!({
4001        "dir_1": {
4002            "file_1.py": "# File contents",
4003        },
4004        "dir_2": {
4005            "file_1.py": "# File contents",
4006        }
4007    });
4008
4009    fs.insert_tree("/project_root_1", worktree_content.clone())
4010        .await;
4011    fs.insert_tree("/project_root_2", worktree_content).await;
4012
4013    let project = Project::test(
4014        fs.clone(),
4015        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
4016        cx,
4017    )
4018    .await;
4019    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4020    let workspace = window
4021        .read_with(cx, |mw, _| mw.workspace().clone())
4022        .unwrap();
4023    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4024    let panel = workspace.update_in(cx, ProjectPanel::new);
4025    cx.run_until_parked();
4026
4027    panel.update_in(cx, |panel, window, cx| {
4028        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
4029    });
4030    cx.executor().run_until_parked();
4031    assert_eq!(
4032        visible_entries_as_strings(&panel, 0..10, cx),
4033        &["> project_root_1", "> project_root_2",]
4034    );
4035}
4036
4037#[gpui::test]
4038async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
4039    init_test_with_editor(cx);
4040
4041    let fs = FakeFs::new(cx.executor());
4042    fs.insert_tree(
4043        "/project_root",
4044        json!({
4045            "dir_1": {
4046                "nested_dir": {
4047                    "file_a.py": "# File contents",
4048                    "file_b.py": "# File contents",
4049                    "file_c.py": "# File contents",
4050                },
4051                "file_1.py": "# File contents",
4052                "file_2.py": "# File contents",
4053                "file_3.py": "# File contents",
4054            },
4055            "dir_2": {
4056                "file_1.py": "# File contents",
4057                "file_2.py": "# File contents",
4058                "file_3.py": "# File contents",
4059            }
4060        }),
4061    )
4062    .await;
4063
4064    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4065    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4066    let workspace = window
4067        .read_with(cx, |mw, _| mw.workspace().clone())
4068        .unwrap();
4069    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4070    let panel = workspace.update_in(cx, ProjectPanel::new);
4071    cx.run_until_parked();
4072
4073    // Open project_root/dir_1 to ensure that a nested directory is expanded
4074    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4075    cx.executor().run_until_parked();
4076    assert_eq!(
4077        visible_entries_as_strings(&panel, 0..10, cx),
4078        &[
4079            "v project_root",
4080            "    v dir_1  <== selected",
4081            "        > nested_dir",
4082            "          file_1.py",
4083            "          file_2.py",
4084            "          file_3.py",
4085            "    > dir_2",
4086        ]
4087    );
4088
4089    // Close root directory
4090    toggle_expand_dir(&panel, "project_root", cx);
4091    cx.executor().run_until_parked();
4092    assert_eq!(
4093        visible_entries_as_strings(&panel, 0..10, cx),
4094        &["> project_root  <== selected"]
4095    );
4096
4097    // Run collapse_all_entries and make sure root is not expanded
4098    panel.update_in(cx, |panel, window, cx| {
4099        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
4100    });
4101    cx.executor().run_until_parked();
4102    assert_eq!(
4103        visible_entries_as_strings(&panel, 0..10, cx),
4104        &["> project_root  <== selected"]
4105    );
4106}
4107
4108#[gpui::test]
4109async fn test_collapse_all_entries_with_invisible_worktree(cx: &mut gpui::TestAppContext) {
4110    init_test_with_editor(cx);
4111
4112    let fs = FakeFs::new(cx.executor());
4113    fs.insert_tree(
4114        "/project_root",
4115        json!({
4116            "dir_1": {
4117                "nested_dir": {
4118                    "file_a.py": "# File contents",
4119                },
4120                "file_1.py": "# File contents",
4121            },
4122            "dir_2": {
4123                "file_1.py": "# File contents",
4124            }
4125        }),
4126    )
4127    .await;
4128    fs.insert_tree(
4129        "/external",
4130        json!({
4131            "external_file.py": "# External file",
4132        }),
4133    )
4134    .await;
4135
4136    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4137    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4138    let workspace = window
4139        .read_with(cx, |mw, _| mw.workspace().clone())
4140        .unwrap();
4141    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4142    let panel = workspace.update_in(cx, ProjectPanel::new);
4143    cx.run_until_parked();
4144
4145    let (_invisible_worktree, _) = project
4146        .update(cx, |project, cx| {
4147            project.find_or_create_worktree("/external/external_file.py", false, cx)
4148        })
4149        .await
4150        .unwrap();
4151    cx.run_until_parked();
4152
4153    assert_eq!(
4154        visible_entries_as_strings(&panel, 0..10, cx),
4155        &["v project_root", "    > dir_1", "    > dir_2",],
4156        "invisible worktree should not appear in project panel"
4157    );
4158
4159    toggle_expand_dir(&panel, "project_root/dir_1", cx);
4160    cx.executor().run_until_parked();
4161
4162    panel.update_in(cx, |panel, window, cx| {
4163        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
4164    });
4165    cx.executor().run_until_parked();
4166    assert_eq!(
4167        visible_entries_as_strings(&panel, 0..10, cx),
4168        &["v project_root", "    > dir_1  <== selected", "    > dir_2",],
4169        "with single visible worktree, root should stay expanded even if invisible worktrees exist"
4170    );
4171}
4172
4173#[gpui::test]
4174async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
4175    init_test(cx);
4176
4177    let fs = FakeFs::new(cx.executor());
4178    fs.as_fake().insert_tree(path!("/root"), json!({})).await;
4179    let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
4180    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4181    let workspace = window
4182        .read_with(cx, |mw, _| mw.workspace().clone())
4183        .unwrap();
4184    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4185    let panel = workspace.update_in(cx, ProjectPanel::new);
4186    cx.run_until_parked();
4187
4188    // Make a new buffer with no backing file
4189    workspace.update_in(cx, |workspace, window, cx| {
4190        Editor::new_file(workspace, &Default::default(), window, cx)
4191    });
4192
4193    cx.executor().run_until_parked();
4194
4195    // "Save as" the buffer, creating a new backing file for it
4196    let save_task = workspace.update_in(cx, |workspace, window, cx| {
4197        workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
4198    });
4199
4200    cx.executor().run_until_parked();
4201    cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
4202    save_task.await.unwrap();
4203
4204    // Rename the file
4205    select_path(&panel, "root/new", cx);
4206    assert_eq!(
4207        visible_entries_as_strings(&panel, 0..10, cx),
4208        &["v root", "      new  <== selected  <== marked"]
4209    );
4210    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4211    panel.update_in(cx, |panel, window, cx| {
4212        panel
4213            .filename_editor
4214            .update(cx, |editor, cx| editor.set_text("newer", window, cx));
4215    });
4216    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
4217
4218    cx.executor().run_until_parked();
4219    assert_eq!(
4220        visible_entries_as_strings(&panel, 0..10, cx),
4221        &["v root", "      newer  <== selected"]
4222    );
4223
4224    workspace
4225        .update_in(cx, |workspace, window, cx| {
4226            workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
4227        })
4228        .await
4229        .unwrap();
4230
4231    cx.executor().run_until_parked();
4232    // assert that saving the file doesn't restore "new"
4233    assert_eq!(
4234        visible_entries_as_strings(&panel, 0..10, cx),
4235        &["v root", "      newer  <== selected"]
4236    );
4237}
4238
4239// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
4240// you can't rename a directory which some program has already open. This is a
4241// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
4242// to it, and thus renaming it will fail on Windows.
4243// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
4244// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
4245#[gpui::test]
4246#[cfg_attr(target_os = "windows", ignore)]
4247async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
4248    init_test_with_editor(cx);
4249
4250    let fs = FakeFs::new(cx.executor());
4251    fs.insert_tree(
4252        "/root1",
4253        json!({
4254            "dir1": {
4255                "file1.txt": "content 1",
4256            },
4257        }),
4258    )
4259    .await;
4260
4261    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4262    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4263    let workspace = window
4264        .read_with(cx, |mw, _| mw.workspace().clone())
4265        .unwrap();
4266    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4267    let panel = workspace.update_in(cx, ProjectPanel::new);
4268    cx.run_until_parked();
4269
4270    toggle_expand_dir(&panel, "root1/dir1", cx);
4271
4272    assert_eq!(
4273        visible_entries_as_strings(&panel, 0..20, cx),
4274        &["v root1", "    v dir1  <== selected", "          file1.txt",],
4275        "Initial state with worktrees"
4276    );
4277
4278    select_path(&panel, "root1", cx);
4279    assert_eq!(
4280        visible_entries_as_strings(&panel, 0..20, cx),
4281        &["v root1  <== selected", "    v dir1", "          file1.txt",],
4282    );
4283
4284    // Rename root1 to new_root1
4285    panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4286
4287    assert_eq!(
4288        visible_entries_as_strings(&panel, 0..20, cx),
4289        &[
4290            "v [EDITOR: 'root1']  <== selected",
4291            "    v dir1",
4292            "          file1.txt",
4293        ],
4294    );
4295
4296    let confirm = panel.update_in(cx, |panel, window, cx| {
4297        panel
4298            .filename_editor
4299            .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
4300        panel.confirm_edit(true, window, cx).unwrap()
4301    });
4302    confirm.await.unwrap();
4303    cx.run_until_parked();
4304    assert_eq!(
4305        visible_entries_as_strings(&panel, 0..20, cx),
4306        &[
4307            "v new_root1  <== selected",
4308            "    v dir1",
4309            "          file1.txt",
4310        ],
4311        "Should update worktree name"
4312    );
4313
4314    // Ensure internal paths have been updated
4315    select_path(&panel, "new_root1/dir1/file1.txt", cx);
4316    assert_eq!(
4317        visible_entries_as_strings(&panel, 0..20, cx),
4318        &[
4319            "v new_root1",
4320            "    v dir1",
4321            "          file1.txt  <== selected",
4322        ],
4323        "Files in renamed worktree are selectable"
4324    );
4325}
4326
4327#[gpui::test]
4328async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
4329    init_test_with_editor(cx);
4330
4331    let fs = FakeFs::new(cx.executor());
4332    fs.insert_tree(
4333        "/root1",
4334        json!({
4335            "dir1": { "file1.txt": "content" },
4336            "file2.txt": "content",
4337        }),
4338    )
4339    .await;
4340    fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
4341        .await;
4342
4343    // Test 1: Single worktree, hide_root=true - rename should be blocked
4344    {
4345        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4346        let window =
4347            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4348        let workspace = window
4349            .read_with(cx, |mw, _| mw.workspace().clone())
4350            .unwrap();
4351        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4352
4353        cx.update(|_, cx| {
4354            let settings = *ProjectPanelSettings::get_global(cx);
4355            ProjectPanelSettings::override_global(
4356                ProjectPanelSettings {
4357                    hide_root: true,
4358                    ..settings
4359                },
4360                cx,
4361            );
4362        });
4363
4364        let panel = workspace.update_in(cx, ProjectPanel::new);
4365        cx.run_until_parked();
4366
4367        panel.update(cx, |panel, cx| {
4368            let project = panel.project.read(cx);
4369            let worktree = project.visible_worktrees(cx).next().unwrap();
4370            let root_entry = worktree.read(cx).root_entry().unwrap();
4371            panel.selection = Some(SelectedEntry {
4372                worktree_id: worktree.read(cx).id(),
4373                entry_id: root_entry.id,
4374            });
4375        });
4376
4377        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4378
4379        assert!(
4380            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
4381            "Rename should be blocked when hide_root=true with single worktree"
4382        );
4383    }
4384
4385    // Test 2: Multiple worktrees, hide_root=true - rename should work
4386    {
4387        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4388        let window =
4389            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4390        let workspace = window
4391            .read_with(cx, |mw, _| mw.workspace().clone())
4392            .unwrap();
4393        let cx = &mut VisualTestContext::from_window(window.into(), cx);
4394
4395        cx.update(|_, cx| {
4396            let settings = *ProjectPanelSettings::get_global(cx);
4397            ProjectPanelSettings::override_global(
4398                ProjectPanelSettings {
4399                    hide_root: true,
4400                    ..settings
4401                },
4402                cx,
4403            );
4404        });
4405
4406        let panel = workspace.update_in(cx, ProjectPanel::new);
4407        cx.run_until_parked();
4408
4409        select_path(&panel, "root1", cx);
4410        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4411
4412        #[cfg(target_os = "windows")]
4413        assert!(
4414            panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
4415            "Rename should be blocked on Windows even with multiple worktrees"
4416        );
4417
4418        #[cfg(not(target_os = "windows"))]
4419        {
4420            assert!(
4421                panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
4422                "Rename should work with multiple worktrees on non-Windows when hide_root=true"
4423            );
4424            panel.update_in(cx, |panel, window, cx| {
4425                panel.cancel(&menu::Cancel, window, cx)
4426            });
4427        }
4428    }
4429}
4430
4431async fn setup_three_worktree_panel(
4432    cx: &mut gpui::TestAppContext,
4433) -> (Entity<ProjectPanel>, VisualTestContext) {
4434    init_test(cx);
4435
4436    let fs = FakeFs::new(cx.executor());
4437    fs.insert_tree("/root1", json!({ "a.txt": "" })).await;
4438    fs.insert_tree("/root2", json!({ "b.txt": "" })).await;
4439    fs.insert_tree("/root3", json!({ "c.txt": "" })).await;
4440
4441    let project = Project::test(
4442        fs.clone(),
4443        ["/root1".as_ref(), "/root2".as_ref(), "/root3".as_ref()],
4444        cx,
4445    )
4446    .await;
4447    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4448    let workspace = window
4449        .read_with(cx, |mw, _| mw.workspace().clone())
4450        .unwrap();
4451    let mut cx = VisualTestContext::from_window(window.into(), cx);
4452    let panel = workspace.update_in(&mut cx, ProjectPanel::new);
4453    cx.run_until_parked();
4454    (panel, cx)
4455}
4456
4457#[gpui::test]
4458async fn test_drag_worktree_root_reorders_worktrees(cx: &mut gpui::TestAppContext) {
4459    let (panel, mut cx) = setup_three_worktree_panel(cx).await;
4460    let cx = &mut cx;
4461
4462    assert_eq!(
4463        visible_entries_as_strings(&panel, 0..20, cx),
4464        &[
4465            "v root1",
4466            "      a.txt",
4467            "v root2",
4468            "      b.txt",
4469            "v root3",
4470            "      c.txt",
4471        ],
4472        "worktrees should start in insertion order"
4473    );
4474
4475    // [r1, r2, r3] -> [r2, r1, r3].
4476    drag_entries_onto(&panel, &["root1"], "root2", false, cx);
4477    assert_eq!(
4478        visible_entries_as_strings(&panel, 0..20, cx),
4479        &[
4480            "v root2",
4481            "      b.txt",
4482            "v root1",
4483            "      a.txt",
4484            "v root3",
4485            "      c.txt",
4486        ],
4487        "dragging root1 onto root2 should swap their positions"
4488    );
4489
4490    // [r2, r1, r3] -> [r3, r2, r1].
4491    drag_entries_onto(&panel, &["root3"], "root2", false, cx);
4492    assert_eq!(
4493        visible_entries_as_strings(&panel, 0..20, cx),
4494        &[
4495            "v root3",
4496            "      c.txt",
4497            "v root2",
4498            "      b.txt",
4499            "v root1",
4500            "      a.txt",
4501        ],
4502        "dragging the last root onto the first should move it to the front"
4503    );
4504}
4505
4506#[gpui::test]
4507async fn test_drag_including_worktree_root_only_reorders(cx: &mut gpui::TestAppContext) {
4508    let (panel, mut cx) = setup_three_worktree_panel(cx).await;
4509    let cx = &mut cx;
4510
4511    // Drag {root1, root2/b.txt} onto root3's root entry: only the worktree
4512    // reorder should happen and b.txt must stay in root2.
4513    drag_entries_onto(&panel, &["root1", "root2/b.txt"], "root3", false, cx);
4514    assert_eq!(
4515        visible_entries_as_strings(&panel, 0..20, cx),
4516        &[
4517            "v root2",
4518            "      b.txt",
4519            "v root3",
4520            "      c.txt",
4521            "v root1",
4522            "      a.txt",
4523        ],
4524        "dropping a mixed selection on a root should only reorder worktrees"
4525    );
4526
4527    // Drag {root2, root3/c.txt} onto root1/a.txt (a non-root entry): the root
4528    // still reorders to root1's position and c.txt must stay in root3.
4529    drag_entries_onto(&panel, &["root2", "root3/c.txt"], "root1/a.txt", true, cx);
4530    assert_eq!(
4531        visible_entries_as_strings(&panel, 0..20, cx),
4532        &[
4533            "v root3",
4534            "      c.txt",
4535            "v root1",
4536            "      a.txt",
4537            "v root2",
4538            "      b.txt",
4539        ],
4540        "dropping a mixed selection on a non-root entry should only reorder worktrees"
4541    );
4542
4543    // With the copy modifier held, a selection containing a root should still
4544    // only reorder worktrees and copy nothing.
4545    cx.simulate_modifiers_change(gpui::Modifiers {
4546        alt: true,
4547        control: true,
4548        ..Default::default()
4549    });
4550    drag_entries_onto(&panel, &["root3", "root1/a.txt"], "root2", false, cx);
4551    cx.simulate_modifiers_change(Default::default());
4552    assert_eq!(
4553        visible_entries_as_strings(&panel, 0..20, cx),
4554        &[
4555            "v root1",
4556            "      a.txt",
4557            "v root2",
4558            "      b.txt",
4559            "v root3",
4560            "      c.txt",
4561        ],
4562        "copy-dragging a mixed selection should only reorder worktrees and copy nothing"
4563    );
4564}
4565
4566#[gpui::test]
4567async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4568    init_test_with_editor(cx);
4569    let fs = FakeFs::new(cx.executor());
4570    fs.insert_tree(
4571        "/project_root",
4572        json!({
4573            "dir_1": {
4574                "nested_dir": {
4575                    "file_a.py": "# File contents",
4576                }
4577            },
4578            "file_1.py": "# File contents",
4579        }),
4580    )
4581    .await;
4582
4583    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4584    let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
4585    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4586    let workspace = window
4587        .read_with(cx, |mw, _| mw.workspace().clone())
4588        .unwrap();
4589    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4590    let panel = workspace.update_in(cx, ProjectPanel::new);
4591    cx.run_until_parked();
4592
4593    cx.update(|window, cx| {
4594        panel.update(cx, |this, cx| {
4595            this.select_next(&Default::default(), window, cx);
4596            this.expand_selected_entry(&Default::default(), window, cx);
4597        })
4598    });
4599    cx.run_until_parked();
4600
4601    cx.update(|window, cx| {
4602        panel.update(cx, |this, cx| {
4603            this.expand_selected_entry(&Default::default(), window, cx);
4604        })
4605    });
4606    cx.run_until_parked();
4607
4608    cx.update(|window, cx| {
4609        panel.update(cx, |this, cx| {
4610            this.select_next(&Default::default(), window, cx);
4611            this.expand_selected_entry(&Default::default(), window, cx);
4612        })
4613    });
4614    cx.run_until_parked();
4615
4616    cx.update(|window, cx| {
4617        panel.update(cx, |this, cx| {
4618            this.select_next(&Default::default(), window, cx);
4619        })
4620    });
4621    cx.run_until_parked();
4622
4623    assert_eq!(
4624        visible_entries_as_strings(&panel, 0..10, cx),
4625        &[
4626            "v project_root",
4627            "    v dir_1",
4628            "        v nested_dir",
4629            "              file_a.py  <== selected",
4630            "      file_1.py",
4631        ]
4632    );
4633    let modifiers_with_shift = gpui::Modifiers {
4634        shift: true,
4635        ..Default::default()
4636    };
4637    cx.run_until_parked();
4638    cx.simulate_modifiers_change(modifiers_with_shift);
4639    cx.update(|window, cx| {
4640        panel.update(cx, |this, cx| {
4641            this.select_next(&Default::default(), window, cx);
4642        })
4643    });
4644    assert_eq!(
4645        visible_entries_as_strings(&panel, 0..10, cx),
4646        &[
4647            "v project_root",
4648            "    v dir_1",
4649            "        v nested_dir",
4650            "              file_a.py",
4651            "      file_1.py  <== selected  <== marked",
4652        ]
4653    );
4654    cx.update(|window, cx| {
4655        panel.update(cx, |this, cx| {
4656            this.select_previous(&Default::default(), window, cx);
4657        })
4658    });
4659    assert_eq!(
4660        visible_entries_as_strings(&panel, 0..10, cx),
4661        &[
4662            "v project_root",
4663            "    v dir_1",
4664            "        v nested_dir",
4665            "              file_a.py  <== selected  <== marked",
4666            "      file_1.py  <== marked",
4667        ]
4668    );
4669    cx.update(|window, cx| {
4670        panel.update(cx, |this, cx| {
4671            let drag = DraggedSelection {
4672                active_selection: this.selection.unwrap(),
4673                marked_selections: this.marked_entries.clone().into(),
4674            };
4675            let target_entry = this
4676                .project
4677                .read(cx)
4678                .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
4679                .unwrap();
4680            this.drag_onto(&drag, target_entry.id, false, window, cx);
4681        });
4682    });
4683    cx.run_until_parked();
4684    assert_eq!(
4685        visible_entries_as_strings(&panel, 0..10, cx),
4686        &[
4687            "v project_root",
4688            "    v dir_1",
4689            "        v nested_dir",
4690            "      file_1.py  <== marked",
4691            "      file_a.py  <== selected  <== marked",
4692        ]
4693    );
4694    // ESC clears out all marks
4695    cx.update(|window, cx| {
4696        panel.update(cx, |this, cx| {
4697            this.cancel(&menu::Cancel, window, cx);
4698        })
4699    });
4700    cx.executor().run_until_parked();
4701    assert_eq!(
4702        visible_entries_as_strings(&panel, 0..10, cx),
4703        &[
4704            "v project_root",
4705            "    v dir_1",
4706            "        v nested_dir",
4707            "      file_1.py",
4708            "      file_a.py  <== selected",
4709        ]
4710    );
4711    // ESC clears out all marks
4712    cx.update(|window, cx| {
4713        panel.update(cx, |this, cx| {
4714            this.select_previous(&SelectPrevious, window, cx);
4715            this.select_next(&SelectNext, window, cx);
4716        })
4717    });
4718    assert_eq!(
4719        visible_entries_as_strings(&panel, 0..10, cx),
4720        &[
4721            "v project_root",
4722            "    v dir_1",
4723            "        v nested_dir",
4724            "      file_1.py  <== marked",
4725            "      file_a.py  <== selected  <== marked",
4726        ]
4727    );
4728    cx.simulate_modifiers_change(Default::default());
4729    cx.update(|window, cx| {
4730        panel.update(cx, |this, cx| {
4731            this.cut(&Cut, window, cx);
4732            this.select_previous(&SelectPrevious, window, cx);
4733            this.select_previous(&SelectPrevious, window, cx);
4734
4735            this.paste(&Paste, window, cx);
4736            this.update_visible_entries(None, false, false, window, cx);
4737        })
4738    });
4739    cx.run_until_parked();
4740    assert_eq!(
4741        visible_entries_as_strings(&panel, 0..10, cx),
4742        &[
4743            "v project_root",
4744            "    v dir_1",
4745            "        v nested_dir",
4746            "              file_1.py  <== marked",
4747            "              file_a.py  <== selected  <== marked",
4748        ]
4749    );
4750    cx.simulate_modifiers_change(modifiers_with_shift);
4751    cx.update(|window, cx| {
4752        panel.update(cx, |this, cx| {
4753            this.expand_selected_entry(&Default::default(), window, cx);
4754            this.select_next(&SelectNext, window, cx);
4755            this.select_next(&SelectNext, window, cx);
4756        })
4757    });
4758    submit_deletion(&panel, cx);
4759    assert_eq!(
4760        visible_entries_as_strings(&panel, 0..10, cx),
4761        &[
4762            "v project_root",
4763            "    v dir_1",
4764            "        v nested_dir  <== selected",
4765        ]
4766    );
4767}
4768
4769#[gpui::test]
4770async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
4771    init_test(cx);
4772
4773    let fs = FakeFs::new(cx.executor());
4774    fs.insert_tree(
4775        "/root",
4776        json!({
4777            "a": {
4778                "b": {
4779                    "c": {
4780                        "d": {}
4781                    }
4782                }
4783            },
4784            "target_destination": {}
4785        }),
4786    )
4787    .await;
4788
4789    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4790    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4791    let workspace = window
4792        .read_with(cx, |mw, _| mw.workspace().clone())
4793        .unwrap();
4794    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4795
4796    cx.update(|_, cx| {
4797        let settings = *ProjectPanelSettings::get_global(cx);
4798        ProjectPanelSettings::override_global(
4799            ProjectPanelSettings {
4800                auto_fold_dirs: true,
4801                ..settings
4802            },
4803            cx,
4804        );
4805    });
4806
4807    let panel = workspace.update_in(cx, ProjectPanel::new);
4808    cx.run_until_parked();
4809
4810    // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
4811    select_path(&panel, "root/a/b/c/d", cx);
4812    panel.update_in(cx, |panel, window, cx| {
4813        let drag = DraggedSelection {
4814            active_selection: *panel.selection.as_ref().unwrap(),
4815            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4816        };
4817        let target_entry = panel
4818            .project
4819            .read(cx)
4820            .visible_worktrees(cx)
4821            .next()
4822            .unwrap()
4823            .read(cx)
4824            .entry_for_path(rel_path("target_destination"))
4825            .unwrap();
4826        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4827    });
4828    cx.executor().run_until_parked();
4829
4830    assert_eq!(
4831        visible_entries_as_strings(&panel, 0..10, cx),
4832        &[
4833            "v root",
4834            "    > a/b/c",
4835            "    > target_destination/d  <== selected"
4836        ],
4837        "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
4838    );
4839
4840    // Reset
4841    select_path(&panel, "root/target_destination/d", cx);
4842    panel.update_in(cx, |panel, window, cx| {
4843        let drag = DraggedSelection {
4844            active_selection: *panel.selection.as_ref().unwrap(),
4845            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4846        };
4847        let target_entry = panel
4848            .project
4849            .read(cx)
4850            .visible_worktrees(cx)
4851            .next()
4852            .unwrap()
4853            .read(cx)
4854            .entry_for_path(rel_path("a/b/c"))
4855            .unwrap();
4856        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4857    });
4858    cx.executor().run_until_parked();
4859
4860    // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
4861    select_path(&panel, "root/a/b", cx);
4862    panel.update_in(cx, |panel, window, cx| {
4863        let drag = DraggedSelection {
4864            active_selection: *panel.selection.as_ref().unwrap(),
4865            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4866        };
4867        let target_entry = panel
4868            .project
4869            .read(cx)
4870            .visible_worktrees(cx)
4871            .next()
4872            .unwrap()
4873            .read(cx)
4874            .entry_for_path(rel_path("target_destination"))
4875            .unwrap();
4876        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4877    });
4878    cx.executor().run_until_parked();
4879
4880    assert_eq!(
4881        visible_entries_as_strings(&panel, 0..10, cx),
4882        &["v root", "    v a", "    > target_destination/b/c/d"],
4883        "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
4884    );
4885
4886    // Reset
4887    select_path(&panel, "root/target_destination/b", cx);
4888    panel.update_in(cx, |panel, window, cx| {
4889        let drag = DraggedSelection {
4890            active_selection: *panel.selection.as_ref().unwrap(),
4891            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4892        };
4893        let target_entry = panel
4894            .project
4895            .read(cx)
4896            .visible_worktrees(cx)
4897            .next()
4898            .unwrap()
4899            .read(cx)
4900            .entry_for_path(rel_path("a"))
4901            .unwrap();
4902        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4903    });
4904    cx.executor().run_until_parked();
4905
4906    // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
4907    select_path(&panel, "root/a", cx);
4908    panel.update_in(cx, |panel, window, cx| {
4909        let drag = DraggedSelection {
4910            active_selection: *panel.selection.as_ref().unwrap(),
4911            marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4912        };
4913        let target_entry = panel
4914            .project
4915            .read(cx)
4916            .visible_worktrees(cx)
4917            .next()
4918            .unwrap()
4919            .read(cx)
4920            .entry_for_path(rel_path("target_destination"))
4921            .unwrap();
4922        panel.drag_onto(&drag, target_entry.id, false, window, cx);
4923    });
4924    cx.executor().run_until_parked();
4925
4926    assert_eq!(
4927        visible_entries_as_strings(&panel, 0..10, cx),
4928        &["v root", "    > target_destination/a/b/c/d"],
4929        "Moving first directory 'a' should move whole 'a/b/c/d' chain"
4930    );
4931}
4932
4933#[gpui::test]
4934async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) {
4935    init_test(cx);
4936
4937    let fs = FakeFs::new(cx.executor());
4938    fs.insert_tree(
4939        "/root",
4940        json!({
4941            "a": {
4942                "b": {
4943                    "c": {}
4944                }
4945            },
4946            "e": {
4947                "f": {
4948                    "g": {}
4949                }
4950            },
4951            "target": {}
4952        }),
4953    )
4954    .await;
4955
4956    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4957    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4958    let workspace = window
4959        .read_with(cx, |mw, _| mw.workspace().clone())
4960        .unwrap();
4961    let cx = &mut VisualTestContext::from_window(window.into(), cx);
4962
4963    cx.update(|_, cx| {
4964        let settings = *ProjectPanelSettings::get_global(cx);
4965        ProjectPanelSettings::override_global(
4966            ProjectPanelSettings {
4967                auto_fold_dirs: true,
4968                ..settings
4969            },
4970            cx,
4971        );
4972    });
4973
4974    let panel = workspace.update_in(cx, ProjectPanel::new);
4975    cx.run_until_parked();
4976
4977    assert_eq!(
4978        visible_entries_as_strings(&panel, 0..10, cx),
4979        &["v root", "    > a/b/c", "    > e/f/g", "    > target"]
4980    );
4981
4982    select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx);
4983    select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx);
4984
4985    panel.update_in(cx, |panel, window, cx| {
4986        let drag = DraggedSelection {
4987            active_selection: *panel.selection.as_ref().unwrap(),
4988            marked_selections: panel.marked_entries.clone().into(),
4989        };
4990        let target_entry = panel
4991            .project
4992            .read(cx)
4993            .visible_worktrees(cx)
4994            .next()
4995            .unwrap()
4996            .read(cx)
4997            .entry_for_path(rel_path("target"))
4998            .unwrap();
4999        panel.drag_onto(&drag, target_entry.id, false, window, cx);
5000    });
5001    cx.executor().run_until_parked();
5002
5003    // After dragging 'b/c' and 'f/g' should be moved to target
5004    assert_eq!(
5005        visible_entries_as_strings(&panel, 0..10, cx),
5006        &[
5007            "v root",
5008            "    > a",
5009            "    > e",
5010            "    v target",
5011            "        > b/c",
5012            "        > f/g  <== selected  <== marked"
5013        ],
5014        "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'"
5015    );
5016}
5017
5018#[gpui::test]
5019async fn test_dragging_same_named_files_preserves_one_source_on_conflict(
5020    cx: &mut gpui::TestAppContext,
5021) {
5022    init_test(cx);
5023
5024    let fs = FakeFs::new(cx.executor());
5025    fs.insert_tree(
5026        "/root",
5027        json!({
5028            "dir_a": {
5029                "shared.txt": "from a"
5030            },
5031            "dir_b": {
5032                "shared.txt": "from b"
5033            }
5034        }),
5035    )
5036    .await;
5037
5038    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5039    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5040    let workspace = window
5041        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
5042        .unwrap();
5043    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5044    let panel = workspace.update_in(cx, ProjectPanel::new);
5045    cx.run_until_parked();
5046
5047    panel.update_in(cx, |panel, window, cx| {
5048        let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = {
5049            let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap();
5050            let worktree = worktree.read(cx);
5051            let root_entry_id = worktree.root_entry().unwrap().id;
5052            let worktree_id = worktree.id();
5053            let entry_a_id = worktree
5054                .entry_for_path(rel_path("dir_a/shared.txt"))
5055                .unwrap()
5056                .id;
5057            let entry_b_id = worktree
5058                .entry_for_path(rel_path("dir_b/shared.txt"))
5059                .unwrap()
5060                .id;
5061            (root_entry_id, worktree_id, entry_a_id, entry_b_id)
5062        };
5063
5064        let drag = DraggedSelection {
5065            active_selection: SelectedEntry {
5066                worktree_id,
5067                entry_id: entry_a_id,
5068            },
5069            marked_selections: Arc::new([
5070                SelectedEntry {
5071                    worktree_id,
5072                    entry_id: entry_a_id,
5073                },
5074                SelectedEntry {
5075                    worktree_id,
5076                    entry_id: entry_b_id,
5077                },
5078            ]),
5079        };
5080
5081        panel.drag_onto(&drag, root_entry_id, false, window, cx);
5082    });
5083    cx.executor().run_until_parked();
5084
5085    let files = fs.files();
5086    assert!(files.contains(&PathBuf::from(path!("/root/shared.txt"))));
5087
5088    let remaining_sources = [
5089        PathBuf::from(path!("/root/dir_a/shared.txt")),
5090        PathBuf::from(path!("/root/dir_b/shared.txt")),
5091    ]
5092    .into_iter()
5093    .filter(|path| files.contains(path))
5094    .count();
5095
5096    assert_eq!(
5097        remaining_sources, 1,
5098        "one conflicting source file should remain in place"
5099    );
5100}
5101
5102#[gpui::test]
5103async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5104    init_test(cx);
5105
5106    let fs = FakeFs::new(cx.executor());
5107    fs.insert_tree(
5108        "/root_a",
5109        json!({
5110            "src": {
5111                "lib.rs": "",
5112                "main.rs": ""
5113            },
5114            "docs": {
5115                "guide.md": ""
5116            },
5117            "multi": {
5118                "alpha.txt": "",
5119                "beta.txt": ""
5120            }
5121        }),
5122    )
5123    .await;
5124    fs.insert_tree(
5125        "/root_b",
5126        json!({
5127            "dst": {
5128                "existing.md": ""
5129            },
5130            "target.txt": ""
5131        }),
5132    )
5133    .await;
5134
5135    let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
5136    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5137    let workspace = window
5138        .read_with(cx, |mw, _| mw.workspace().clone())
5139        .unwrap();
5140    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5141    let panel = workspace.update_in(cx, ProjectPanel::new);
5142    cx.run_until_parked();
5143
5144    // Case 1: move a file onto a directory in another worktree.
5145    select_path(&panel, "root_a/src/main.rs", cx);
5146    drag_selection_to(&panel, "root_b/dst", false, cx);
5147    assert!(
5148        find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
5149        "Dragged file should appear under destination worktree"
5150    );
5151    assert_eq!(
5152        find_project_entry(&panel, "root_a/src/main.rs", cx),
5153        None,
5154        "Dragged file should be removed from the source worktree"
5155    );
5156
5157    // Case 2: drop a file onto another worktree file so it lands in the parent directory.
5158    select_path(&panel, "root_a/docs/guide.md", cx);
5159    drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
5160    assert!(
5161        find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
5162        "Dropping onto a file should place the entry beside the target file"
5163    );
5164    assert_eq!(
5165        find_project_entry(&panel, "root_a/docs/guide.md", cx),
5166        None,
5167        "Source file should be removed after the move"
5168    );
5169
5170    // Case 3: move an entire directory.
5171    select_path(&panel, "root_a/src", cx);
5172    drag_selection_to(&panel, "root_b/dst", false, cx);
5173    assert!(
5174        find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
5175        "Dragging a directory should move its nested contents"
5176    );
5177    assert_eq!(
5178        find_project_entry(&panel, "root_a/src", cx),
5179        None,
5180        "Directory should no longer exist in the source worktree"
5181    );
5182
5183    // Case 4: multi-selection drag between worktrees.
5184    panel.update(cx, |panel, _| panel.marked_entries.clear());
5185    select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
5186    select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
5187    drag_selection_to(&panel, "root_b/dst", false, cx);
5188    assert!(
5189        find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
5190            && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
5191        "All marked entries should move to the destination worktree"
5192    );
5193    assert_eq!(
5194        find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
5195        None,
5196        "Marked entries should be removed from the origin worktree"
5197    );
5198    assert_eq!(
5199        find_project_entry(&panel, "root_a/multi/beta.txt", cx),
5200        None,
5201        "Marked entries should be removed from the origin worktree"
5202    );
5203}
5204
5205#[gpui::test]
5206async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
5207    init_test(cx);
5208
5209    let fs = FakeFs::new(cx.executor());
5210    fs.insert_tree(
5211        "/root",
5212        json!({
5213            "src": {
5214                "folder1": {
5215                    "mod.rs": "// folder1 mod"
5216                },
5217                "folder2": {
5218                    "mod.rs": "// folder2 mod"
5219                },
5220                "folder3": {
5221                    "mod.rs": "// folder3 mod",
5222                    "helper.rs": "// helper"
5223                },
5224                "main.rs": ""
5225            }
5226        }),
5227    )
5228    .await;
5229
5230    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5231    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5232    let workspace = window
5233        .read_with(cx, |mw, _| mw.workspace().clone())
5234        .unwrap();
5235    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5236    let panel = workspace.update_in(cx, ProjectPanel::new);
5237    cx.run_until_parked();
5238
5239    toggle_expand_dir(&panel, "root/src", cx);
5240    toggle_expand_dir(&panel, "root/src/folder1", cx);
5241    toggle_expand_dir(&panel, "root/src/folder2", cx);
5242    toggle_expand_dir(&panel, "root/src/folder3", cx);
5243    cx.run_until_parked();
5244
5245    // Case 1: Dragging a folder and a file from a sibling folder together.
5246    panel.update(cx, |panel, _| panel.marked_entries.clear());
5247    select_path_with_mark(&panel, "root/src/folder1", cx);
5248    select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
5249
5250    drag_selection_to(&panel, "root", false, cx);
5251
5252    assert!(
5253        find_project_entry(&panel, "root/folder1", cx).is_some(),
5254        "folder1 should be at root after drag"
5255    );
5256    assert!(
5257        find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
5258        "folder1/mod.rs should still be inside folder1 after drag"
5259    );
5260    assert_eq!(
5261        find_project_entry(&panel, "root/src/folder1", cx),
5262        None,
5263        "folder1 should no longer be in src"
5264    );
5265    assert!(
5266        find_project_entry(&panel, "root/mod.rs", cx).is_some(),
5267        "mod.rs from folder2 should be at root"
5268    );
5269
5270    // Case 2: Dragging a folder and its own child together.
5271    panel.update(cx, |panel, _| panel.marked_entries.clear());
5272    select_path_with_mark(&panel, "root/src/folder3", cx);
5273    select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
5274
5275    drag_selection_to(&panel, "root", false, cx);
5276
5277    assert!(
5278        find_project_entry(&panel, "root/folder3", cx).is_some(),
5279        "folder3 should be at root after drag"
5280    );
5281    assert!(
5282        find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
5283        "folder3/mod.rs should still be inside folder3"
5284    );
5285    assert!(
5286        find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
5287        "folder3/helper.rs should still be inside folder3"
5288    );
5289}
5290
5291#[gpui::test]
5292async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
5293    init_test_with_editor(cx);
5294    cx.update(|cx| {
5295        cx.update_global::<SettingsStore, _>(|store, cx| {
5296            store.update_user_settings(cx, |settings| {
5297                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5298                settings
5299                    .project_panel
5300                    .get_or_insert_default()
5301                    .auto_reveal_entries = Some(false);
5302            });
5303        })
5304    });
5305
5306    let fs = FakeFs::new(cx.background_executor.clone());
5307    fs.insert_tree(
5308        "/project_root",
5309        json!({
5310            ".git": {},
5311            ".gitignore": "**/gitignored_dir",
5312            "dir_1": {
5313                "file_1.py": "# File 1_1 contents",
5314                "file_2.py": "# File 1_2 contents",
5315                "file_3.py": "# File 1_3 contents",
5316                "gitignored_dir": {
5317                    "file_a.py": "# File contents",
5318                    "file_b.py": "# File contents",
5319                    "file_c.py": "# File contents",
5320                },
5321            },
5322            "dir_2": {
5323                "file_1.py": "# File 2_1 contents",
5324                "file_2.py": "# File 2_2 contents",
5325                "file_3.py": "# File 2_3 contents",
5326            }
5327        }),
5328    )
5329    .await;
5330
5331    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5332    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5333    let workspace = window
5334        .read_with(cx, |mw, _| mw.workspace().clone())
5335        .unwrap();
5336    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5337    let panel = workspace.update_in(cx, ProjectPanel::new);
5338    cx.run_until_parked();
5339
5340    assert_eq!(
5341        visible_entries_as_strings(&panel, 0..20, cx),
5342        &[
5343            "v project_root",
5344            "    > .git",
5345            "    > dir_1",
5346            "    > dir_2",
5347            "      .gitignore",
5348        ]
5349    );
5350
5351    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5352        .expect("dir 1 file is not ignored and should have an entry");
5353    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5354        .expect("dir 2 file is not ignored and should have an entry");
5355    let gitignored_dir_file =
5356        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5357    assert_eq!(
5358        gitignored_dir_file, None,
5359        "File in the gitignored dir should not have an entry before its dir is toggled"
5360    );
5361
5362    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5363    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5364    cx.executor().run_until_parked();
5365    assert_eq!(
5366        visible_entries_as_strings(&panel, 0..20, cx),
5367        &[
5368            "v project_root",
5369            "    > .git",
5370            "    v dir_1",
5371            "        v gitignored_dir  <== selected",
5372            "              file_a.py",
5373            "              file_b.py",
5374            "              file_c.py",
5375            "          file_1.py",
5376            "          file_2.py",
5377            "          file_3.py",
5378            "    > dir_2",
5379            "      .gitignore",
5380        ],
5381        "Should show gitignored dir file list in the project panel"
5382    );
5383    let gitignored_dir_file =
5384        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5385            .expect("after gitignored dir got opened, a file entry should be present");
5386
5387    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5388    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5389    assert_eq!(
5390        visible_entries_as_strings(&panel, 0..20, cx),
5391        &[
5392            "v project_root",
5393            "    > .git",
5394            "    > dir_1  <== selected",
5395            "    > dir_2",
5396            "      .gitignore",
5397        ],
5398        "Should hide all dir contents again and prepare for the auto reveal test"
5399    );
5400
5401    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5402        panel.update(cx, |panel, cx| {
5403            panel.project.update(cx, |_, cx| {
5404                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5405            })
5406        });
5407        cx.run_until_parked();
5408        assert_eq!(
5409            visible_entries_as_strings(&panel, 0..20, cx),
5410            &[
5411                "v project_root",
5412                "    > .git",
5413                "    > dir_1  <== selected",
5414                "    > dir_2",
5415                "      .gitignore",
5416            ],
5417            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5418        );
5419    }
5420
5421    cx.update(|_, cx| {
5422        cx.update_global::<SettingsStore, _>(|store, cx| {
5423            store.update_user_settings(cx, |settings| {
5424                settings
5425                    .project_panel
5426                    .get_or_insert_default()
5427                    .auto_reveal_entries = Some(true)
5428            });
5429        })
5430    });
5431
5432    panel.update(cx, |panel, cx| {
5433        panel.project.update(cx, |_, cx| {
5434            cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
5435        })
5436    });
5437    cx.run_until_parked();
5438    assert_eq!(
5439        visible_entries_as_strings(&panel, 0..20, cx),
5440        &[
5441            "v project_root",
5442            "    > .git",
5443            "    v dir_1",
5444            "        > gitignored_dir",
5445            "          file_1.py  <== selected  <== marked",
5446            "          file_2.py",
5447            "          file_3.py",
5448            "    > dir_2",
5449            "      .gitignore",
5450        ],
5451        "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
5452    );
5453
5454    panel.update(cx, |panel, cx| {
5455        panel.project.update(cx, |_, cx| {
5456            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5457        })
5458    });
5459    cx.run_until_parked();
5460    assert_eq!(
5461        visible_entries_as_strings(&panel, 0..20, cx),
5462        &[
5463            "v project_root",
5464            "    > .git",
5465            "    v dir_1",
5466            "        > gitignored_dir",
5467            "          file_1.py",
5468            "          file_2.py",
5469            "          file_3.py",
5470            "    v dir_2",
5471            "          file_1.py  <== selected  <== marked",
5472            "          file_2.py",
5473            "          file_3.py",
5474            "      .gitignore",
5475        ],
5476        "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
5477    );
5478
5479    panel.update(cx, |panel, cx| {
5480        panel.project.update(cx, |_, cx| {
5481            cx.emit(project::Event::ActiveEntryChanged(Some(
5482                gitignored_dir_file,
5483            )))
5484        })
5485    });
5486    cx.run_until_parked();
5487    assert_eq!(
5488        visible_entries_as_strings(&panel, 0..20, cx),
5489        &[
5490            "v project_root",
5491            "    > .git",
5492            "    v dir_1",
5493            "        > gitignored_dir",
5494            "          file_1.py",
5495            "          file_2.py",
5496            "          file_3.py",
5497            "    v dir_2",
5498            "          file_1.py  <== selected  <== marked",
5499            "          file_2.py",
5500            "          file_3.py",
5501            "      .gitignore",
5502        ],
5503        "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5504    );
5505
5506    panel.update(cx, |panel, cx| {
5507        panel.project.update(cx, |_, cx| {
5508            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5509        })
5510    });
5511    cx.run_until_parked();
5512    assert_eq!(
5513        visible_entries_as_strings(&panel, 0..20, cx),
5514        &[
5515            "v project_root",
5516            "    > .git",
5517            "    v dir_1",
5518            "        v gitignored_dir",
5519            "              file_a.py  <== selected  <== marked",
5520            "              file_b.py",
5521            "              file_c.py",
5522            "          file_1.py",
5523            "          file_2.py",
5524            "          file_3.py",
5525            "    v dir_2",
5526            "          file_1.py",
5527            "          file_2.py",
5528            "          file_3.py",
5529            "      .gitignore",
5530        ],
5531        "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5532    );
5533
5534    panel.update(cx, |panel, cx| {
5535        panel.project.update(cx, |_, cx| {
5536            cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5537        })
5538    });
5539    cx.run_until_parked();
5540    assert_eq!(
5541        visible_entries_as_strings(&panel, 0..20, cx),
5542        &[
5543            "v project_root",
5544            "    > .git",
5545            "    v dir_1",
5546            "        v gitignored_dir",
5547            "              file_a.py",
5548            "              file_b.py",
5549            "              file_c.py",
5550            "          file_1.py",
5551            "          file_2.py",
5552            "          file_3.py",
5553            "    v dir_2",
5554            "          file_1.py  <== selected  <== marked",
5555            "          file_2.py",
5556            "          file_3.py",
5557            "      .gitignore",
5558        ],
5559        "After switching to dir_2_file, it should be selected and marked"
5560    );
5561
5562    panel.update(cx, |panel, cx| {
5563        panel.project.update(cx, |_, cx| {
5564            cx.emit(project::Event::ActiveEntryChanged(Some(
5565                gitignored_dir_file,
5566            )))
5567        })
5568    });
5569    cx.run_until_parked();
5570    assert_eq!(
5571        visible_entries_as_strings(&panel, 0..20, cx),
5572        &[
5573            "v project_root",
5574            "    > .git",
5575            "    v dir_1",
5576            "        v gitignored_dir",
5577            "              file_a.py  <== selected  <== marked",
5578            "              file_b.py",
5579            "              file_c.py",
5580            "          file_1.py",
5581            "          file_2.py",
5582            "          file_3.py",
5583            "    v dir_2",
5584            "          file_1.py",
5585            "          file_2.py",
5586            "          file_3.py",
5587            "      .gitignore",
5588        ],
5589        "When a gitignored entry is already visible, auto reveal should mark it as selected"
5590    );
5591}
5592
5593#[gpui::test]
5594async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
5595    init_test_with_editor(cx);
5596    cx.update(|cx| {
5597        cx.update_global::<SettingsStore, _>(|store, cx| {
5598            store.update_user_settings(cx, |settings| {
5599                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5600                settings.project.worktree.file_scan_inclusions =
5601                    Some(vec!["always_included_but_ignored_dir/*".to_string()]);
5602                settings
5603                    .project_panel
5604                    .get_or_insert_default()
5605                    .auto_reveal_entries = Some(false)
5606            });
5607        })
5608    });
5609
5610    let fs = FakeFs::new(cx.background_executor.clone());
5611    fs.insert_tree(
5612        "/project_root",
5613        json!({
5614            ".git": {},
5615            ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
5616            "dir_1": {
5617                "file_1.py": "# File 1_1 contents",
5618                "file_2.py": "# File 1_2 contents",
5619                "file_3.py": "# File 1_3 contents",
5620                "gitignored_dir": {
5621                    "file_a.py": "# File contents",
5622                    "file_b.py": "# File contents",
5623                    "file_c.py": "# File contents",
5624                },
5625            },
5626            "dir_2": {
5627                "file_1.py": "# File 2_1 contents",
5628                "file_2.py": "# File 2_2 contents",
5629                "file_3.py": "# File 2_3 contents",
5630            },
5631            "always_included_but_ignored_dir": {
5632                "file_a.py": "# File contents",
5633                "file_b.py": "# File contents",
5634                "file_c.py": "# File contents",
5635            },
5636        }),
5637    )
5638    .await;
5639
5640    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5641    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5642    let workspace = window
5643        .read_with(cx, |mw, _| mw.workspace().clone())
5644        .unwrap();
5645    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5646    let panel = workspace.update_in(cx, ProjectPanel::new);
5647    cx.run_until_parked();
5648
5649    assert_eq!(
5650        visible_entries_as_strings(&panel, 0..20, cx),
5651        &[
5652            "v project_root",
5653            "    > .git",
5654            "    > always_included_but_ignored_dir",
5655            "    > dir_1",
5656            "    > dir_2",
5657            "      .gitignore",
5658        ]
5659    );
5660
5661    let gitignored_dir_file =
5662        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5663    let always_included_but_ignored_dir_file = find_project_entry(
5664        &panel,
5665        "project_root/always_included_but_ignored_dir/file_a.py",
5666        cx,
5667    )
5668    .expect("file that is .gitignored but set to always be included should have an entry");
5669    assert_eq!(
5670        gitignored_dir_file, None,
5671        "File in the gitignored dir should not have an entry unless its directory is toggled"
5672    );
5673
5674    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5675    cx.run_until_parked();
5676    cx.update(|_, cx| {
5677        cx.update_global::<SettingsStore, _>(|store, cx| {
5678            store.update_user_settings(cx, |settings| {
5679                settings
5680                    .project_panel
5681                    .get_or_insert_default()
5682                    .auto_reveal_entries = Some(true)
5683            });
5684        })
5685    });
5686
5687    panel.update(cx, |panel, cx| {
5688        panel.project.update(cx, |_, cx| {
5689            cx.emit(project::Event::ActiveEntryChanged(Some(
5690                always_included_but_ignored_dir_file,
5691            )))
5692        })
5693    });
5694    cx.run_until_parked();
5695
5696    assert_eq!(
5697        visible_entries_as_strings(&panel, 0..20, cx),
5698        &[
5699            "v project_root",
5700            "    > .git",
5701            "    v always_included_but_ignored_dir",
5702            "          file_a.py  <== selected  <== marked",
5703            "          file_b.py",
5704            "          file_c.py",
5705            "    v dir_1",
5706            "        > gitignored_dir",
5707            "          file_1.py",
5708            "          file_2.py",
5709            "          file_3.py",
5710            "    > dir_2",
5711            "      .gitignore",
5712        ],
5713        "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
5714    );
5715}
5716
5717#[gpui::test]
5718async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5719    init_test_with_editor(cx);
5720    cx.update(|cx| {
5721        cx.update_global::<SettingsStore, _>(|store, cx| {
5722            store.update_user_settings(cx, |settings| {
5723                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5724                settings
5725                    .project_panel
5726                    .get_or_insert_default()
5727                    .auto_reveal_entries = Some(false)
5728            });
5729        })
5730    });
5731
5732    let fs = FakeFs::new(cx.background_executor.clone());
5733    fs.insert_tree(
5734        "/project_root",
5735        json!({
5736            ".git": {},
5737            ".gitignore": "**/gitignored_dir",
5738            "dir_1": {
5739                "file_1.py": "# File 1_1 contents",
5740                "file_2.py": "# File 1_2 contents",
5741                "file_3.py": "# File 1_3 contents",
5742                "gitignored_dir": {
5743                    "file_a.py": "# File contents",
5744                    "file_b.py": "# File contents",
5745                    "file_c.py": "# File contents",
5746                },
5747            },
5748            "dir_2": {
5749                "file_1.py": "# File 2_1 contents",
5750                "file_2.py": "# File 2_2 contents",
5751                "file_3.py": "# File 2_3 contents",
5752            }
5753        }),
5754    )
5755    .await;
5756
5757    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5758    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5759    let workspace = window
5760        .read_with(cx, |mw, _| mw.workspace().clone())
5761        .unwrap();
5762    let cx = &mut VisualTestContext::from_window(window.into(), cx);
5763    let panel = workspace.update_in(cx, ProjectPanel::new);
5764    cx.run_until_parked();
5765
5766    assert_eq!(
5767        visible_entries_as_strings(&panel, 0..20, cx),
5768        &[
5769            "v project_root",
5770            "    > .git",
5771            "    > dir_1",
5772            "    > dir_2",
5773            "      .gitignore",
5774        ]
5775    );
5776
5777    let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5778        .expect("dir 1 file is not ignored and should have an entry");
5779    let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5780        .expect("dir 2 file is not ignored and should have an entry");
5781    let gitignored_dir_file =
5782        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5783    assert_eq!(
5784        gitignored_dir_file, None,
5785        "File in the gitignored dir should not have an entry before its dir is toggled"
5786    );
5787
5788    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5789    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5790    cx.run_until_parked();
5791    assert_eq!(
5792        visible_entries_as_strings(&panel, 0..20, cx),
5793        &[
5794            "v project_root",
5795            "    > .git",
5796            "    v dir_1",
5797            "        v gitignored_dir  <== selected",
5798            "              file_a.py",
5799            "              file_b.py",
5800            "              file_c.py",
5801            "          file_1.py",
5802            "          file_2.py",
5803            "          file_3.py",
5804            "    > dir_2",
5805            "      .gitignore",
5806        ],
5807        "Should show gitignored dir file list in the project panel"
5808    );
5809    let gitignored_dir_file =
5810        find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5811            .expect("after gitignored dir got opened, a file entry should be present");
5812
5813    toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5814    toggle_expand_dir(&panel, "project_root/dir_1", cx);
5815    assert_eq!(
5816        visible_entries_as_strings(&panel, 0..20, cx),
5817        &[
5818            "v project_root",
5819            "    > .git",
5820            "    > dir_1  <== selected",
5821            "    > dir_2",
5822            "      .gitignore",
5823        ],
5824        "Should hide all dir contents again and prepare for the explicit reveal test"
5825    );
5826
5827    for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5828        panel.update(cx, |panel, cx| {
5829            panel.project.update(cx, |_, cx| {
5830                cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5831            })
5832        });
5833        cx.run_until_parked();
5834        assert_eq!(
5835            visible_entries_as_strings(&panel, 0..20, cx),
5836            &[
5837                "v project_root",
5838                "    > .git",
5839                "    > dir_1  <== selected",
5840                "    > dir_2",
5841                "      .gitignore",
5842            ],
5843            "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5844        );
5845    }
5846
5847    panel.update(cx, |panel, cx| {
5848        panel.project.update(cx, |_, cx| {
5849            cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5850        })
5851    });
5852    cx.run_until_parked();
5853    assert_eq!(
5854        visible_entries_as_strings(&panel, 0..20, cx),
5855        &[
5856            "v project_root",
5857            "    > .git",
5858            "    v dir_1",
5859            "        > gitignored_dir",
5860            "          file_1.py  <== selected  <== marked",
5861            "          file_2.py",
5862            "          file_3.py",
5863            "    > dir_2",
5864            "      .gitignore",
5865        ],
5866        "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5867    );
5868
5869    panel.update(cx, |panel, cx| {
5870        panel.project.update(cx, |_, cx| {
5871            cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5872        })
5873    });
5874    cx.run_until_parked();
5875    assert_eq!(
5876        visible_entries_as_strings(&panel, 0..20, cx),
5877        &[
5878            "v project_root",
5879            "    > .git",
5880            "    v dir_1",
5881            "        > gitignored_dir",
5882            "          file_1.py",
5883            "          file_2.py",
5884            "          file_3.py",
5885            "    v dir_2",
5886            "          file_1.py  <== selected  <== marked",
5887            "          file_2.py",
5888            "          file_3.py",
5889            "      .gitignore",
5890        ],
5891        "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5892    );
5893
5894    panel.update(cx, |panel, cx| {
5895        panel.project.update(cx, |_, cx| {
5896            cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5897        })
5898    });
5899    cx.run_until_parked();
5900    assert_eq!(
5901        visible_entries_as_strings(&panel, 0..20, cx),
5902        &[
5903            "v project_root",
5904            "    > .git",
5905            "    v dir_1",
5906            "        v gitignored_dir",
5907            "              file_a.py  <== selected  <== marked",
5908            "              file_b.py",
5909            "              file_c.py",
5910            "          file_1.py",
5911            "          file_2.py",
5912            "          file_3.py",
5913            "    v dir_2",
5914            "          file_1.py",
5915            "          file_2.py",
5916            "          file_3.py",
5917            "      .gitignore",
5918        ],
5919        "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5920    );
5921}
5922
5923/// Mirrors real multi-buffer views (`ProjectDiagnosticsEditor`, `ProjectDiff`,
5924/// etc.): the workspace `Item` is a thin wrapper that holds an inner `Editor`
5925/// and re-emits its events.
5926mod multibuffer_wrapper {
5927    use editor::{Editor, EditorEvent};
5928    use gpui::{
5929        App, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement,
5930        ParentElement, Render, SharedString, Subscription, Window, div,
5931    };
5932    use workspace::item::{Item, ItemEvent, TabContentParams};
5933
5934    pub struct TestMultibufferWrapper {
5935        pub editor: Entity<Editor>,
5936        _subscription: Subscription,
5937    }
5938
5939    impl TestMultibufferWrapper {
5940        pub fn new(editor: Entity<Editor>, cx: &mut Context<Self>) -> Self {
5941            let _subscription = cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| {
5942                cx.emit(event.clone());
5943            });
5944            Self {
5945                editor,
5946                _subscription,
5947            }
5948        }
5949    }
5950
5951    impl EventEmitter<EditorEvent> for TestMultibufferWrapper {}
5952
5953    impl Focusable for TestMultibufferWrapper {
5954        fn focus_handle(&self, cx: &App) -> FocusHandle {
5955            self.editor.read(cx).focus_handle(cx)
5956        }
5957    }
5958
5959    impl Render for TestMultibufferWrapper {
5960        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5961            div().child(self.editor.clone())
5962        }
5963    }
5964
5965    impl Item for TestMultibufferWrapper {
5966        type Event = EditorEvent;
5967
5968        fn tab_content_text(&self, _: usize, _: &App) -> SharedString {
5969            "wrapper".into()
5970        }
5971
5972        fn for_each_project_item(
5973            &self,
5974            cx: &App,
5975            f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
5976        ) {
5977            self.editor.read(cx).for_each_project_item(cx, f)
5978        }
5979
5980        fn active_project_path(&self, cx: &App) -> Option<project::ProjectPath> {
5981            self.editor.read(cx).active_project_path(cx)
5982        }
5983
5984        fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
5985            Editor::to_item_events(event, f)
5986        }
5987
5988        fn tab_content(&self, params: TabContentParams, _: &Window, cx: &App) -> gpui::AnyElement {
5989            ui::Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
5990                .into_any_element()
5991        }
5992    }
5993}
5994
5995#[gpui::test]
5996async fn test_autoreveal_follows_multibuffer_selection(cx: &mut gpui::TestAppContext) {
5997    use editor::{
5998        Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, ToOffset,
5999    };
6000    use language::Point;
6001    use multibuffer_wrapper::TestMultibufferWrapper;
6002
6003    init_test_with_editor(cx);
6004    cx.update(|cx| {
6005        cx.update_global::<SettingsStore, _>(|store, cx| {
6006            store.update_user_settings(cx, |settings| {
6007                settings
6008                    .project_panel
6009                    .get_or_insert_default()
6010                    .auto_reveal_entries = Some(true);
6011            });
6012        });
6013    });
6014
6015    let fs = FakeFs::new(cx.background_executor.clone());
6016    fs.insert_tree(
6017        path!("/project_root"),
6018        json!({
6019            "dir_1": { "file_1.py": "alpha 1\nalpha 2\nalpha 3\n" },
6020            "dir_2": { "file_2.py": "beta 1\nbeta 2\nbeta 3\n" },
6021        }),
6022    )
6023    .await;
6024
6025    let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
6026    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6027    let workspace = window
6028        .read_with(cx, |mw, _| mw.workspace().clone())
6029        .unwrap();
6030    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6031    let panel = workspace.update_in(cx, ProjectPanel::new);
6032    cx.run_until_parked();
6033
6034    let buffer_1 = project
6035        .update(cx, |project, cx| {
6036            let project_path = project
6037                .find_project_path("project_root/dir_1/file_1.py", cx)
6038                .unwrap();
6039            project.open_buffer(project_path, cx)
6040        })
6041        .await
6042        .unwrap();
6043    let buffer_2 = project
6044        .update(cx, |project, cx| {
6045            let project_path = project
6046                .find_project_path("project_root/dir_2/file_2.py", cx)
6047                .unwrap();
6048            project.open_buffer(project_path, cx)
6049        })
6050        .await
6051        .unwrap();
6052
6053    let multi_buffer = cx.update(|_, cx| {
6054        cx.new(|cx| {
6055            let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite);
6056            multi_buffer.set_excerpts_for_path(
6057                PathKey::sorted(0),
6058                buffer_1.clone(),
6059                [Point::new(0, 0)..Point::new(2, 0)],
6060                0,
6061                cx,
6062            );
6063            multi_buffer.set_excerpts_for_path(
6064                PathKey::sorted(1),
6065                buffer_2.clone(),
6066                [Point::new(0, 0)..Point::new(2, 0)],
6067                0,
6068                cx,
6069            );
6070            multi_buffer
6071        })
6072    });
6073
6074    let inner_editor = cx.update(|window, cx| {
6075        cx.new(|cx| {
6076            Editor::new(
6077                EditorMode::full(),
6078                multi_buffer.clone(),
6079                Some(project.clone()),
6080                window,
6081                cx,
6082            )
6083        })
6084    });
6085
6086    // Wrap the multibuffer editor in an `Item`, mirroring real multibuffer
6087    // views (`ProjectDiagnosticsEditor`, `ProjectDiff`, etc.). Auto-reveal
6088    // should follow the inner editor's active buffer.
6089    workspace.update_in(cx, |workspace, window, cx| {
6090        let wrapper = cx.new(|cx| TestMultibufferWrapper::new(inner_editor.clone(), cx));
6091        workspace.add_item_to_active_pane(Box::new(wrapper), None, true, window, cx);
6092    });
6093    cx.run_until_parked();
6094
6095    assert_eq!(
6096        visible_entries_as_strings(&panel, 0..20, cx),
6097        &[
6098            "v project_root",
6099            "    v dir_1",
6100            "          file_1.py  <== selected  <== marked",
6101            "    > dir_2",
6102        ],
6103        "When a multibuffer becomes active, its first excerpt's file should be revealed"
6104    );
6105
6106    let buffer_2_offset = multi_buffer.read_with(cx, |multi_buffer, cx| {
6107        let snapshot = multi_buffer.snapshot(cx);
6108        let buffer_2_id = buffer_2.read(cx).remote_id();
6109        let excerpt = snapshot
6110            .excerpts_for_buffer(buffer_2_id)
6111            .next()
6112            .expect("buffer_2 excerpt must exist");
6113        snapshot
6114            .anchor_in_excerpt(excerpt.context.start)
6115            .expect("excerpt anchor must resolve")
6116            .to_offset(&snapshot)
6117    });
6118
6119    inner_editor.update_in(cx, |editor, window, cx| {
6120        editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
6121            s.select_ranges([buffer_2_offset..buffer_2_offset]);
6122        });
6123    });
6124    cx.run_until_parked();
6125
6126    assert_eq!(
6127        visible_entries_as_strings(&panel, 0..20, cx),
6128        &[
6129            "v project_root",
6130            "    v dir_1",
6131            "          file_1.py",
6132            "    v dir_2",
6133            "          file_2.py  <== selected  <== marked",
6134        ],
6135        "Moving the cursor into a different excerpt buffer should reveal that buffer's entry"
6136    );
6137
6138    // Wrappers re-emit inner-editor events through `to_item_events`, so a
6139    // benign `TitleChanged` (e.g. diagnostic summary updates) ultimately
6140    // reaches `Workspace::active_item_path_changed`. The active path should be
6141    // recomputed from the wrapper instead of falling back to a stale selection.
6142    inner_editor.update(cx, |_, cx| cx.emit(EditorEvent::TitleChanged));
6143    cx.run_until_parked();
6144
6145    assert_eq!(
6146        visible_entries_as_strings(&panel, 0..20, cx),
6147        &[
6148            "v project_root",
6149            "    v dir_1",
6150            "          file_1.py",
6151            "    v dir_2",
6152            "          file_2.py  <== selected  <== marked",
6153        ],
6154        "Wrapper-level title updates must not clobber the inner editor's reveal"
6155    );
6156}
6157
6158#[gpui::test]
6159async fn test_reveal_in_project_panel_fallback(cx: &mut gpui::TestAppContext) {
6160    init_test_with_editor(cx);
6161    let fs = FakeFs::new(cx.background_executor.clone());
6162    fs.insert_tree(
6163        "/workspace",
6164        json!({
6165            "README.md": ""
6166        }),
6167    )
6168    .await;
6169
6170    let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
6171    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6172    let workspace = window
6173        .read_with(cx, |mw, _| mw.workspace().clone())
6174        .unwrap();
6175    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6176    let panel = workspace.update_in(cx, |workspace, window, cx| {
6177        let panel = ProjectPanel::new(workspace, window, cx);
6178        workspace.add_panel(panel.clone(), window, cx);
6179        panel
6180    });
6181    cx.run_until_parked();
6182
6183    // Project panel should still be activated and focused, when using `pane:
6184    // reveal in project panel` without an active item.
6185    cx.dispatch_action(workspace::RevealInProjectPanel::default());
6186    cx.run_until_parked();
6187
6188    panel.update_in(cx, |panel, window, cx| {
6189        panel
6190            .workspace
6191            .update(cx, |workspace, cx| {
6192                assert!(
6193                    workspace.active_item(cx).is_none(),
6194                    "Workspace should not have an active item."
6195                );
6196            })
6197            .unwrap();
6198
6199        assert!(
6200            panel.focus_handle(cx).is_focused(window),
6201            "Project panel should be focused, even when there's no active item."
6202        );
6203    });
6204
6205    // When working with a file that doesn't belong to an open project, we
6206    // should still activate the project panel on `pane: reveal in project
6207    // panel`.
6208    fs.insert_tree(
6209        "/external",
6210        json!({
6211            "file.txt": "External File",
6212        }),
6213    )
6214    .await;
6215
6216    let (worktree, _) = project
6217        .update(cx, |project, cx| {
6218            project.find_or_create_worktree("/external/file.txt", false, cx)
6219        })
6220        .await
6221        .unwrap();
6222
6223    workspace
6224        .update_in(cx, |workspace, window, cx| {
6225            let worktree_id = worktree.read(cx).id();
6226            let path = rel_path("").into();
6227            let project_path = ProjectPath { worktree_id, path };
6228
6229            workspace.open_path(project_path, None, true, window, cx)
6230        })
6231        .await
6232        .unwrap();
6233    cx.run_until_parked();
6234
6235    panel.update_in(cx, |panel, window, cx| {
6236        assert!(
6237            !panel.focus_handle(cx).is_focused(window),
6238            "Project panel should not be focused after opening an external file."
6239        );
6240    });
6241
6242    cx.dispatch_action(workspace::RevealInProjectPanel::default());
6243    cx.run_until_parked();
6244
6245    panel.update_in(cx, |panel, window, cx| {
6246        panel
6247            .workspace
6248            .update(cx, |workspace, cx| {
6249                assert!(
6250                    workspace.active_item(cx).is_some(),
6251                    "Workspace should have an active item."
6252                );
6253            })
6254            .unwrap();
6255
6256        assert!(
6257            panel.focus_handle(cx).is_focused(window),
6258            "Project panel should be focused even for invisible worktree entry."
6259        );
6260    });
6261
6262    // Focus again on the center pane so we're sure that the focus doesn't
6263    // remain on the project panel, otherwise later assertions wouldn't matter.
6264    panel.update_in(cx, |panel, window, cx| {
6265        panel
6266            .workspace
6267            .update(cx, |workspace, cx| {
6268                workspace.focus_center_pane(window, cx);
6269            })
6270            .log_err();
6271
6272        assert!(
6273            !panel.focus_handle(cx).is_focused(window),
6274            "Project panel should not be focused after focusing on center pane."
6275        );
6276    });
6277
6278    panel.update_in(cx, |panel, window, cx| {
6279        assert!(
6280            !panel.focus_handle(cx).is_focused(window),
6281            "Project panel should not be focused after focusing the center pane."
6282        );
6283    });
6284
6285    // Create an unsaved buffer and verify that pane: reveal in project panel`
6286    // still activates and focuses the panel.
6287    let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
6288    pane.update_in(cx, |pane, window, cx| {
6289        let item = cx.new(|cx| TestItem::new(cx).with_label("Unsaved buffer"));
6290        pane.add_item(Box::new(item), false, false, None, window, cx);
6291    });
6292
6293    cx.dispatch_action(workspace::RevealInProjectPanel::default());
6294    cx.run_until_parked();
6295
6296    panel.update_in(cx, |panel, window, cx| {
6297        panel
6298            .workspace
6299            .update(cx, |workspace, cx| {
6300                assert!(
6301                    workspace.active_item(cx).is_some(),
6302                    "Workspace should have an active item."
6303                );
6304            })
6305            .unwrap();
6306
6307        assert!(
6308            panel.focus_handle(cx).is_focused(window),
6309            "Project panel should be focused even for an unsaved buffer."
6310        );
6311    });
6312}
6313
6314#[gpui::test]
6315async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6316    init_test(cx);
6317    cx.update(|cx| {
6318        cx.update_global::<SettingsStore, _>(|store, cx| {
6319            store.update_user_settings(cx, |settings| {
6320                settings.project.worktree.file_scan_exclusions =
6321                    Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6322            });
6323        });
6324    });
6325
6326    cx.update(|cx| {
6327        register_project_item::<TestProjectItemView>(cx);
6328    });
6329
6330    let fs = FakeFs::new(cx.executor());
6331    fs.insert_tree(
6332        "/root1",
6333        json!({
6334            ".dockerignore": "",
6335            ".git": {
6336                "HEAD": "",
6337            },
6338        }),
6339    )
6340    .await;
6341
6342    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6343    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6344    let workspace = window
6345        .read_with(cx, |mw, _| mw.workspace().clone())
6346        .unwrap();
6347    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6348    let panel = workspace.update_in(cx, |workspace, window, cx| {
6349        let panel = ProjectPanel::new(workspace, window, cx);
6350        workspace.add_panel(panel.clone(), window, cx);
6351        panel
6352    });
6353    cx.run_until_parked();
6354
6355    select_path(&panel, "root1", cx);
6356    assert_eq!(
6357        visible_entries_as_strings(&panel, 0..10, cx),
6358        &["v root1  <== selected", "      .dockerignore",]
6359    );
6360    workspace.update_in(cx, |workspace, _, cx| {
6361        assert!(
6362            workspace.active_item(cx).is_none(),
6363            "Should have no active items in the beginning"
6364        );
6365    });
6366
6367    let excluded_file_path = ".git/COMMIT_EDITMSG";
6368    let excluded_dir_path = "excluded_dir";
6369
6370    panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6371    cx.run_until_parked();
6372    panel.update_in(cx, |panel, window, cx| {
6373        assert!(panel.filename_editor.read(cx).is_focused(window));
6374    });
6375    panel
6376        .update_in(cx, |panel, window, cx| {
6377            panel.filename_editor.update(cx, |editor, cx| {
6378                editor.set_text(excluded_file_path, window, cx)
6379            });
6380            panel.confirm_edit(true, window, cx).unwrap()
6381        })
6382        .await
6383        .unwrap();
6384
6385    assert_eq!(
6386        visible_entries_as_strings(&panel, 0..13, cx),
6387        &["v root1", "      .dockerignore"],
6388        "Excluded dir should not be shown after opening a file in it"
6389    );
6390    panel.update_in(cx, |panel, window, cx| {
6391        assert!(
6392            !panel.filename_editor.read(cx).is_focused(window),
6393            "Should have closed the file name editor"
6394        );
6395    });
6396    workspace.update_in(cx, |workspace, _, cx| {
6397        let active_entry_path = workspace
6398            .active_item(cx)
6399            .expect("should have opened and activated the excluded item")
6400            .act_as::<TestProjectItemView>(cx)
6401            .expect("should have opened the corresponding project item for the excluded item")
6402            .read(cx)
6403            .path
6404            .clone();
6405        assert_eq!(
6406            active_entry_path.path.as_ref(),
6407            rel_path(excluded_file_path),
6408            "Should open the excluded file"
6409        );
6410
6411        assert!(
6412            workspace.notification_ids().is_empty(),
6413            "Should have no notifications after opening an excluded file"
6414        );
6415    });
6416    assert!(
6417        fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
6418        "Should have created the excluded file"
6419    );
6420
6421    select_path(&panel, "root1", cx);
6422    panel.update_in(cx, |panel, window, cx| {
6423        panel.new_directory(&NewDirectory, window, cx)
6424    });
6425    cx.run_until_parked();
6426    panel.update_in(cx, |panel, window, cx| {
6427        assert!(panel.filename_editor.read(cx).is_focused(window));
6428    });
6429    panel
6430        .update_in(cx, |panel, window, cx| {
6431            panel.filename_editor.update(cx, |editor, cx| {
6432                editor.set_text(excluded_file_path, window, cx)
6433            });
6434            panel.confirm_edit(true, window, cx).unwrap()
6435        })
6436        .await
6437        .unwrap();
6438    cx.run_until_parked();
6439    assert_eq!(
6440        visible_entries_as_strings(&panel, 0..13, cx),
6441        &["v root1", "      .dockerignore"],
6442        "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
6443    );
6444    panel.update_in(cx, |panel, window, cx| {
6445        assert!(
6446            !panel.filename_editor.read(cx).is_focused(window),
6447            "Should have closed the file name editor"
6448        );
6449    });
6450    workspace.update_in(cx, |workspace, _, cx| {
6451        let notifications = workspace.notification_ids();
6452        assert_eq!(
6453            notifications.len(),
6454            1,
6455            "Should receive one notification with the error message"
6456        );
6457        workspace.dismiss_notification(notifications.first().unwrap(), cx);
6458        assert!(workspace.notification_ids().is_empty());
6459    });
6460
6461    select_path(&panel, "root1", cx);
6462    panel.update_in(cx, |panel, window, cx| {
6463        panel.new_directory(&NewDirectory, window, cx)
6464    });
6465    cx.run_until_parked();
6466
6467    panel.update_in(cx, |panel, window, cx| {
6468        assert!(panel.filename_editor.read(cx).is_focused(window));
6469    });
6470
6471    panel
6472        .update_in(cx, |panel, window, cx| {
6473            panel.filename_editor.update(cx, |editor, cx| {
6474                editor.set_text(excluded_dir_path, window, cx)
6475            });
6476            panel.confirm_edit(true, window, cx).unwrap()
6477        })
6478        .await
6479        .unwrap();
6480
6481    cx.run_until_parked();
6482
6483    assert_eq!(
6484        visible_entries_as_strings(&panel, 0..13, cx),
6485        &["v root1", "      .dockerignore"],
6486        "Should not change the project panel after trying to create an excluded directory"
6487    );
6488    panel.update_in(cx, |panel, window, cx| {
6489        assert!(
6490            !panel.filename_editor.read(cx).is_focused(window),
6491            "Should have closed the file name editor"
6492        );
6493    });
6494    workspace.update_in(cx, |workspace, _, cx| {
6495        let notifications = workspace.notification_ids();
6496        assert_eq!(
6497            notifications.len(),
6498            1,
6499            "Should receive one notification explaining that no directory is actually shown"
6500        );
6501        workspace.dismiss_notification(notifications.first().unwrap(), cx);
6502        assert!(workspace.notification_ids().is_empty());
6503    });
6504    assert!(
6505        fs.is_dir(Path::new("/root1/excluded_dir")).await,
6506        "Should have created the excluded directory"
6507    );
6508}
6509
6510#[gpui::test]
6511async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
6512    init_test_with_editor(cx);
6513
6514    let fs = FakeFs::new(cx.executor());
6515    fs.insert_tree(
6516        "/src",
6517        json!({
6518            "test": {
6519                "first.rs": "// First Rust file",
6520                "second.rs": "// Second Rust file",
6521                "third.rs": "// Third Rust file",
6522            }
6523        }),
6524    )
6525    .await;
6526
6527    let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6528    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6529    let workspace = window
6530        .read_with(cx, |mw, _| mw.workspace().clone())
6531        .unwrap();
6532    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6533    let panel = workspace.update_in(cx, |workspace, window, cx| {
6534        let panel = ProjectPanel::new(workspace, window, cx);
6535        workspace.add_panel(panel.clone(), window, cx);
6536        panel
6537    });
6538    cx.run_until_parked();
6539
6540    select_path(&panel, "src", cx);
6541    panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6542    cx.executor().run_until_parked();
6543    assert_eq!(
6544        visible_entries_as_strings(&panel, 0..10, cx),
6545        &[
6546            //
6547            "v src  <== selected",
6548            "    > test"
6549        ]
6550    );
6551    panel.update_in(cx, |panel, window, cx| {
6552        panel.new_directory(&NewDirectory, window, cx)
6553    });
6554    cx.executor().run_until_parked();
6555    panel.update_in(cx, |panel, window, cx| {
6556        assert!(panel.filename_editor.read(cx).is_focused(window));
6557    });
6558    assert_eq!(
6559        visible_entries_as_strings(&panel, 0..10, cx),
6560        &[
6561            //
6562            "v src",
6563            "    > [EDITOR: '']  <== selected",
6564            "    > test"
6565        ]
6566    );
6567
6568    panel.update_in(cx, |panel, window, cx| {
6569        panel.cancel(&menu::Cancel, window, cx);
6570    });
6571    cx.executor().run_until_parked();
6572    assert_eq!(
6573        visible_entries_as_strings(&panel, 0..10, cx),
6574        &[
6575            //
6576            "v src  <== selected",
6577            "    > test"
6578        ]
6579    );
6580
6581    panel.update_in(cx, |panel, window, cx| {
6582        panel.new_directory(&NewDirectory, window, cx)
6583    });
6584    cx.executor().run_until_parked();
6585    panel.update_in(cx, |panel, window, cx| {
6586        assert!(panel.filename_editor.read(cx).is_focused(window));
6587    });
6588    assert_eq!(
6589        visible_entries_as_strings(&panel, 0..10, cx),
6590        &[
6591            //
6592            "v src",
6593            "    > [EDITOR: '']  <== selected",
6594            "    > test"
6595        ]
6596    );
6597    workspace.update_in(cx, |_, window, _| window.blur());
6598    cx.executor().run_until_parked();
6599    assert_eq!(
6600        visible_entries_as_strings(&panel, 0..10, cx),
6601        &[
6602            //
6603            "v src  <== selected",
6604            "    > test"
6605        ]
6606    );
6607}
6608
6609#[gpui::test]
6610async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
6611    init_test_with_editor(cx);
6612
6613    let fs = FakeFs::new(cx.executor());
6614    fs.insert_tree(
6615        "/root",
6616        json!({
6617            "dir1": {
6618                "subdir1": {},
6619                "file1.txt": "",
6620                "file2.txt": "",
6621            },
6622            "dir2": {
6623                "subdir2": {},
6624                "file3.txt": "",
6625                "file4.txt": "",
6626            },
6627            "file5.txt": "",
6628            "file6.txt": "",
6629        }),
6630    )
6631    .await;
6632
6633    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6634    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6635    let workspace = window
6636        .read_with(cx, |mw, _| mw.workspace().clone())
6637        .unwrap();
6638    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6639    let panel = workspace.update_in(cx, ProjectPanel::new);
6640    cx.run_until_parked();
6641
6642    toggle_expand_dir(&panel, "root/dir1", cx);
6643    toggle_expand_dir(&panel, "root/dir2", cx);
6644
6645    // Test Case 1: Delete middle file in directory
6646    select_path(&panel, "root/dir1/file1.txt", cx);
6647    assert_eq!(
6648        visible_entries_as_strings(&panel, 0..15, cx),
6649        &[
6650            "v root",
6651            "    v dir1",
6652            "        > subdir1",
6653            "          file1.txt  <== selected",
6654            "          file2.txt",
6655            "    v dir2",
6656            "        > subdir2",
6657            "          file3.txt",
6658            "          file4.txt",
6659            "      file5.txt",
6660            "      file6.txt",
6661        ],
6662        "Initial state before deleting middle file"
6663    );
6664
6665    submit_deletion(&panel, cx);
6666    assert_eq!(
6667        visible_entries_as_strings(&panel, 0..15, cx),
6668        &[
6669            "v root",
6670            "    v dir1",
6671            "        > subdir1",
6672            "          file2.txt  <== selected",
6673            "    v dir2",
6674            "        > subdir2",
6675            "          file3.txt",
6676            "          file4.txt",
6677            "      file5.txt",
6678            "      file6.txt",
6679        ],
6680        "Should select next file after deleting middle file"
6681    );
6682
6683    // Test Case 2: Delete last file in directory
6684    submit_deletion(&panel, cx);
6685    assert_eq!(
6686        visible_entries_as_strings(&panel, 0..15, cx),
6687        &[
6688            "v root",
6689            "    v dir1",
6690            "        > subdir1  <== selected",
6691            "    v dir2",
6692            "        > subdir2",
6693            "          file3.txt",
6694            "          file4.txt",
6695            "      file5.txt",
6696            "      file6.txt",
6697        ],
6698        "Should select next directory when last file is deleted"
6699    );
6700
6701    // Test Case 3: Delete root level file
6702    select_path(&panel, "root/file6.txt", cx);
6703    assert_eq!(
6704        visible_entries_as_strings(&panel, 0..15, cx),
6705        &[
6706            "v root",
6707            "    v dir1",
6708            "        > subdir1",
6709            "    v dir2",
6710            "        > subdir2",
6711            "          file3.txt",
6712            "          file4.txt",
6713            "      file5.txt",
6714            "      file6.txt  <== selected",
6715        ],
6716        "Initial state before deleting root level file"
6717    );
6718
6719    submit_deletion(&panel, cx);
6720    assert_eq!(
6721        visible_entries_as_strings(&panel, 0..15, cx),
6722        &[
6723            "v root",
6724            "    v dir1",
6725            "        > subdir1",
6726            "    v dir2",
6727            "        > subdir2",
6728            "          file3.txt",
6729            "          file4.txt",
6730            "      file5.txt  <== selected",
6731        ],
6732        "Should select prev entry at root level"
6733    );
6734}
6735
6736#[gpui::test]
6737async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
6738    init_test_with_editor(cx);
6739
6740    let fs = FakeFs::new(cx.executor());
6741    fs.insert_tree(
6742        path!("/root"),
6743        json!({
6744            "aa": "// Testing 1",
6745            "bb": "// Testing 2",
6746            "cc": "// Testing 3",
6747            "dd": "// Testing 4",
6748            "ee": "// Testing 5",
6749            "ff": "// Testing 6",
6750            "gg": "// Testing 7",
6751            "hh": "// Testing 8",
6752            "ii": "// Testing 8",
6753            ".gitignore": "bb\ndd\nee\nff\nii\n'",
6754        }),
6755    )
6756    .await;
6757
6758    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6759    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6760    let workspace = window
6761        .read_with(cx, |mw, _| mw.workspace().clone())
6762        .unwrap();
6763    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6764
6765    // Test 1: Auto selection with one gitignored file next to the deleted file
6766    cx.update(|_, cx| {
6767        let settings = *ProjectPanelSettings::get_global(cx);
6768        ProjectPanelSettings::override_global(
6769            ProjectPanelSettings {
6770                hide_gitignore: true,
6771                ..settings
6772            },
6773            cx,
6774        );
6775    });
6776
6777    let panel = workspace.update_in(cx, ProjectPanel::new);
6778    cx.run_until_parked();
6779
6780    select_path(&panel, "root/aa", cx);
6781    assert_eq!(
6782        visible_entries_as_strings(&panel, 0..10, cx),
6783        &[
6784            "v root",
6785            "      .gitignore",
6786            "      aa  <== selected",
6787            "      cc",
6788            "      gg",
6789            "      hh"
6790        ],
6791        "Initial state should hide files on .gitignore"
6792    );
6793
6794    submit_deletion(&panel, cx);
6795
6796    assert_eq!(
6797        visible_entries_as_strings(&panel, 0..10, cx),
6798        &[
6799            "v root",
6800            "      .gitignore",
6801            "      cc  <== selected",
6802            "      gg",
6803            "      hh"
6804        ],
6805        "Should select next entry not on .gitignore"
6806    );
6807
6808    // Test 2: Auto selection with many gitignored files next to the deleted file
6809    submit_deletion(&panel, cx);
6810    assert_eq!(
6811        visible_entries_as_strings(&panel, 0..10, cx),
6812        &[
6813            "v root",
6814            "      .gitignore",
6815            "      gg  <== selected",
6816            "      hh"
6817        ],
6818        "Should select next entry not on .gitignore"
6819    );
6820
6821    // Test 3: Auto selection of entry before deleted file
6822    select_path(&panel, "root/hh", cx);
6823    assert_eq!(
6824        visible_entries_as_strings(&panel, 0..10, cx),
6825        &[
6826            "v root",
6827            "      .gitignore",
6828            "      gg",
6829            "      hh  <== selected"
6830        ],
6831        "Should select next entry not on .gitignore"
6832    );
6833    submit_deletion(&panel, cx);
6834    assert_eq!(
6835        visible_entries_as_strings(&panel, 0..10, cx),
6836        &["v root", "      .gitignore", "      gg  <== selected"],
6837        "Should select next entry not on .gitignore"
6838    );
6839}
6840
6841#[gpui::test]
6842async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
6843    init_test_with_editor(cx);
6844
6845    let fs = FakeFs::new(cx.executor());
6846    fs.insert_tree(
6847        path!("/root"),
6848        json!({
6849            "dir1": {
6850                "file1": "// Testing",
6851                "file2": "// Testing",
6852                "file3": "// Testing"
6853            },
6854            "aa": "// Testing",
6855            ".gitignore": "file1\nfile3\n",
6856        }),
6857    )
6858    .await;
6859
6860    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6861    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6862    let workspace = window
6863        .read_with(cx, |mw, _| mw.workspace().clone())
6864        .unwrap();
6865    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6866
6867    cx.update(|_, cx| {
6868        let settings = *ProjectPanelSettings::get_global(cx);
6869        ProjectPanelSettings::override_global(
6870            ProjectPanelSettings {
6871                hide_gitignore: true,
6872                ..settings
6873            },
6874            cx,
6875        );
6876    });
6877
6878    let panel = workspace.update_in(cx, ProjectPanel::new);
6879    cx.run_until_parked();
6880
6881    // Test 1: Visible items should exclude files on gitignore
6882    toggle_expand_dir(&panel, "root/dir1", cx);
6883    select_path(&panel, "root/dir1/file2", cx);
6884    assert_eq!(
6885        visible_entries_as_strings(&panel, 0..10, cx),
6886        &[
6887            "v root",
6888            "    v dir1",
6889            "          file2  <== selected",
6890            "      .gitignore",
6891            "      aa"
6892        ],
6893        "Initial state should hide files on .gitignore"
6894    );
6895    submit_deletion(&panel, cx);
6896
6897    // Test 2: Auto selection should go to the parent
6898    assert_eq!(
6899        visible_entries_as_strings(&panel, 0..10, cx),
6900        &[
6901            "v root",
6902            "    v dir1  <== selected",
6903            "      .gitignore",
6904            "      aa"
6905        ],
6906        "Initial state should hide files on .gitignore"
6907    );
6908}
6909
6910#[gpui::test]
6911async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
6912    init_test_with_editor(cx);
6913
6914    let fs = FakeFs::new(cx.executor());
6915    fs.insert_tree(
6916        "/root",
6917        json!({
6918            "dir1": {
6919                "subdir1": {
6920                    "a.txt": "",
6921                    "b.txt": ""
6922                },
6923                "file1.txt": "",
6924            },
6925            "dir2": {
6926                "subdir2": {
6927                    "c.txt": "",
6928                    "d.txt": ""
6929                },
6930                "file2.txt": "",
6931            },
6932            "file3.txt": "",
6933        }),
6934    )
6935    .await;
6936
6937    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6938    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6939    let workspace = window
6940        .read_with(cx, |mw, _| mw.workspace().clone())
6941        .unwrap();
6942    let cx = &mut VisualTestContext::from_window(window.into(), cx);
6943    let panel = workspace.update_in(cx, ProjectPanel::new);
6944    cx.run_until_parked();
6945
6946    toggle_expand_dir(&panel, "root/dir1", cx);
6947    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6948    toggle_expand_dir(&panel, "root/dir2", cx);
6949    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6950
6951    // Test Case 1: Select and delete nested directory with parent
6952    cx.simulate_modifiers_change(gpui::Modifiers {
6953        control: true,
6954        ..Default::default()
6955    });
6956    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6957    select_path_with_mark(&panel, "root/dir1", cx);
6958
6959    assert_eq!(
6960        visible_entries_as_strings(&panel, 0..15, cx),
6961        &[
6962            "v root",
6963            "    v dir1  <== selected  <== marked",
6964            "        v subdir1  <== marked",
6965            "              a.txt",
6966            "              b.txt",
6967            "          file1.txt",
6968            "    v dir2",
6969            "        v subdir2",
6970            "              c.txt",
6971            "              d.txt",
6972            "          file2.txt",
6973            "      file3.txt",
6974        ],
6975        "Initial state before deleting nested directory with parent"
6976    );
6977
6978    submit_deletion(&panel, cx);
6979    assert_eq!(
6980        visible_entries_as_strings(&panel, 0..15, cx),
6981        &[
6982            "v root",
6983            "    v dir2  <== selected",
6984            "        v subdir2",
6985            "              c.txt",
6986            "              d.txt",
6987            "          file2.txt",
6988            "      file3.txt",
6989        ],
6990        "Should select next directory after deleting directory with parent"
6991    );
6992
6993    // Test Case 2: Select mixed files and directories across levels
6994    select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
6995    select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
6996    select_path_with_mark(&panel, "root/file3.txt", cx);
6997
6998    assert_eq!(
6999        visible_entries_as_strings(&panel, 0..15, cx),
7000        &[
7001            "v root",
7002            "    v dir2",
7003            "        v subdir2",
7004            "              c.txt  <== marked",
7005            "              d.txt",
7006            "          file2.txt  <== marked",
7007            "      file3.txt  <== selected  <== marked",
7008        ],
7009        "Initial state before deleting"
7010    );
7011
7012    submit_deletion(&panel, cx);
7013    assert_eq!(
7014        visible_entries_as_strings(&panel, 0..15, cx),
7015        &[
7016            "v root",
7017            "    v dir2  <== selected",
7018            "        v subdir2",
7019            "              d.txt",
7020        ],
7021        "Should select sibling directory"
7022    );
7023}
7024
7025#[gpui::test]
7026async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7027    init_test_with_editor(cx);
7028
7029    let fs = FakeFs::new(cx.executor());
7030    fs.insert_tree(
7031        "/root",
7032        json!({
7033            "dir1": {
7034                "subdir1": {
7035                    "a.txt": "",
7036                    "b.txt": ""
7037                },
7038                "file1.txt": "",
7039            },
7040            "dir2": {
7041                "subdir2": {
7042                    "c.txt": "",
7043                    "d.txt": ""
7044                },
7045                "file2.txt": "",
7046            },
7047            "file3.txt": "",
7048            "file4.txt": "",
7049        }),
7050    )
7051    .await;
7052
7053    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7054    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7055    let workspace = window
7056        .read_with(cx, |mw, _| mw.workspace().clone())
7057        .unwrap();
7058    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7059    let panel = workspace.update_in(cx, ProjectPanel::new);
7060    cx.run_until_parked();
7061
7062    toggle_expand_dir(&panel, "root/dir1", cx);
7063    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7064    toggle_expand_dir(&panel, "root/dir2", cx);
7065    toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7066
7067    // Test Case 1: Select all root files and directories
7068    cx.simulate_modifiers_change(gpui::Modifiers {
7069        control: true,
7070        ..Default::default()
7071    });
7072    select_path_with_mark(&panel, "root/dir1", cx);
7073    select_path_with_mark(&panel, "root/dir2", cx);
7074    select_path_with_mark(&panel, "root/file3.txt", cx);
7075    select_path_with_mark(&panel, "root/file4.txt", cx);
7076    assert_eq!(
7077        visible_entries_as_strings(&panel, 0..20, cx),
7078        &[
7079            "v root",
7080            "    v dir1  <== marked",
7081            "        v subdir1",
7082            "              a.txt",
7083            "              b.txt",
7084            "          file1.txt",
7085            "    v dir2  <== marked",
7086            "        v subdir2",
7087            "              c.txt",
7088            "              d.txt",
7089            "          file2.txt",
7090            "      file3.txt  <== marked",
7091            "      file4.txt  <== selected  <== marked",
7092        ],
7093        "State before deleting all contents"
7094    );
7095
7096    submit_deletion(&panel, cx);
7097    assert_eq!(
7098        visible_entries_as_strings(&panel, 0..20, cx),
7099        &["v root  <== selected"],
7100        "Only empty root directory should remain after deleting all contents"
7101    );
7102}
7103
7104#[gpui::test]
7105async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7106    init_test_with_editor(cx);
7107
7108    let fs = FakeFs::new(cx.executor());
7109    fs.insert_tree(
7110        "/root",
7111        json!({
7112            "dir1": {
7113                "subdir1": {
7114                    "file_a.txt": "content a",
7115                    "file_b.txt": "content b",
7116                },
7117                "subdir2": {
7118                    "file_c.txt": "content c",
7119                },
7120                "file1.txt": "content 1",
7121            },
7122            "dir2": {
7123                "file2.txt": "content 2",
7124            },
7125        }),
7126    )
7127    .await;
7128
7129    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7130    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7131    let workspace = window
7132        .read_with(cx, |mw, _| mw.workspace().clone())
7133        .unwrap();
7134    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7135    let panel = workspace.update_in(cx, ProjectPanel::new);
7136    cx.run_until_parked();
7137
7138    toggle_expand_dir(&panel, "root/dir1", cx);
7139    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7140    toggle_expand_dir(&panel, "root/dir2", cx);
7141    cx.simulate_modifiers_change(gpui::Modifiers {
7142        control: true,
7143        ..Default::default()
7144    });
7145
7146    // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7147    select_path_with_mark(&panel, "root/dir1", cx);
7148    select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7149    select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7150
7151    assert_eq!(
7152        visible_entries_as_strings(&panel, 0..20, cx),
7153        &[
7154            "v root",
7155            "    v dir1  <== marked",
7156            "        v subdir1  <== marked",
7157            "              file_a.txt  <== selected  <== marked",
7158            "              file_b.txt",
7159            "        > subdir2",
7160            "          file1.txt",
7161            "    v dir2",
7162            "          file2.txt",
7163        ],
7164        "State with parent dir, subdir, and file selected"
7165    );
7166    submit_deletion(&panel, cx);
7167    assert_eq!(
7168        visible_entries_as_strings(&panel, 0..20, cx),
7169        &["v root", "    v dir2  <== selected", "          file2.txt",],
7170        "Only dir2 should remain after deletion"
7171    );
7172}
7173
7174#[gpui::test]
7175async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7176    init_test_with_editor(cx);
7177
7178    let fs = FakeFs::new(cx.executor());
7179    // First worktree
7180    fs.insert_tree(
7181        "/root1",
7182        json!({
7183            "dir1": {
7184                "file1.txt": "content 1",
7185                "file2.txt": "content 2",
7186            },
7187            "dir2": {
7188                "file3.txt": "content 3",
7189            },
7190        }),
7191    )
7192    .await;
7193
7194    // Second worktree
7195    fs.insert_tree(
7196        "/root2",
7197        json!({
7198            "dir3": {
7199                "file4.txt": "content 4",
7200                "file5.txt": "content 5",
7201            },
7202            "file6.txt": "content 6",
7203        }),
7204    )
7205    .await;
7206
7207    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7208    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7209    let workspace = window
7210        .read_with(cx, |mw, _| mw.workspace().clone())
7211        .unwrap();
7212    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7213    let panel = workspace.update_in(cx, ProjectPanel::new);
7214    cx.run_until_parked();
7215
7216    // Expand all directories for testing
7217    toggle_expand_dir(&panel, "root1/dir1", cx);
7218    toggle_expand_dir(&panel, "root1/dir2", cx);
7219    toggle_expand_dir(&panel, "root2/dir3", cx);
7220
7221    // Test Case 1: Delete files across different worktrees
7222    cx.simulate_modifiers_change(gpui::Modifiers {
7223        control: true,
7224        ..Default::default()
7225    });
7226    select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7227    select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7228
7229    assert_eq!(
7230        visible_entries_as_strings(&panel, 0..20, cx),
7231        &[
7232            "v root1",
7233            "    v dir1",
7234            "          file1.txt  <== marked",
7235            "          file2.txt",
7236            "    v dir2",
7237            "          file3.txt",
7238            "v root2",
7239            "    v dir3",
7240            "          file4.txt  <== selected  <== marked",
7241            "          file5.txt",
7242            "      file6.txt",
7243        ],
7244        "Initial state with files selected from different worktrees"
7245    );
7246
7247    submit_deletion(&panel, cx);
7248    assert_eq!(
7249        visible_entries_as_strings(&panel, 0..20, cx),
7250        &[
7251            "v root1",
7252            "    v dir1",
7253            "          file2.txt",
7254            "    v dir2",
7255            "          file3.txt",
7256            "v root2",
7257            "    v dir3",
7258            "          file5.txt  <== selected",
7259            "      file6.txt",
7260        ],
7261        "Should select next file in the last worktree after deletion"
7262    );
7263
7264    // Test Case 2: Delete directories from different worktrees
7265    select_path_with_mark(&panel, "root1/dir1", cx);
7266    select_path_with_mark(&panel, "root2/dir3", cx);
7267
7268    assert_eq!(
7269        visible_entries_as_strings(&panel, 0..20, cx),
7270        &[
7271            "v root1",
7272            "    v dir1  <== marked",
7273            "          file2.txt",
7274            "    v dir2",
7275            "          file3.txt",
7276            "v root2",
7277            "    v dir3  <== selected  <== marked",
7278            "          file5.txt",
7279            "      file6.txt",
7280        ],
7281        "State with directories marked from different worktrees"
7282    );
7283
7284    submit_deletion(&panel, cx);
7285    assert_eq!(
7286        visible_entries_as_strings(&panel, 0..20, cx),
7287        &[
7288            "v root1",
7289            "    v dir2",
7290            "          file3.txt",
7291            "v root2",
7292            "      file6.txt  <== selected",
7293        ],
7294        "Should select remaining file in last worktree after directory deletion"
7295    );
7296
7297    // Test Case 4: Delete all remaining files except roots
7298    select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7299    select_path_with_mark(&panel, "root2/file6.txt", cx);
7300
7301    assert_eq!(
7302        visible_entries_as_strings(&panel, 0..20, cx),
7303        &[
7304            "v root1",
7305            "    v dir2",
7306            "          file3.txt  <== marked",
7307            "v root2",
7308            "      file6.txt  <== selected  <== marked",
7309        ],
7310        "State with all remaining files marked"
7311    );
7312
7313    submit_deletion(&panel, cx);
7314    assert_eq!(
7315        visible_entries_as_strings(&panel, 0..20, cx),
7316        &["v root1", "    v dir2", "v root2  <== selected"],
7317        "Second parent root should be selected after deleting"
7318    );
7319}
7320
7321#[gpui::test]
7322async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
7323    init_test_with_editor(cx);
7324
7325    let fs = FakeFs::new(cx.executor());
7326    fs.insert_tree(
7327        "/root",
7328        json!({
7329            "dir1": {
7330                "file1.txt": "",
7331                "file2.txt": "",
7332                "file3.txt": "",
7333            },
7334            "dir2": {
7335                "file4.txt": "",
7336                "file5.txt": "",
7337            },
7338        }),
7339    )
7340    .await;
7341
7342    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7343    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7344    let workspace = window
7345        .read_with(cx, |mw, _| mw.workspace().clone())
7346        .unwrap();
7347    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7348    let panel = workspace.update_in(cx, ProjectPanel::new);
7349    cx.run_until_parked();
7350
7351    toggle_expand_dir(&panel, "root/dir1", cx);
7352    toggle_expand_dir(&panel, "root/dir2", cx);
7353
7354    cx.simulate_modifiers_change(gpui::Modifiers {
7355        control: true,
7356        ..Default::default()
7357    });
7358
7359    select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
7360    select_path(&panel, "root/dir1/file1.txt", cx);
7361
7362    assert_eq!(
7363        visible_entries_as_strings(&panel, 0..15, cx),
7364        &[
7365            "v root",
7366            "    v dir1",
7367            "          file1.txt  <== selected",
7368            "          file2.txt  <== marked",
7369            "          file3.txt",
7370            "    v dir2",
7371            "          file4.txt",
7372            "          file5.txt",
7373        ],
7374        "Initial state with one marked entry and different selection"
7375    );
7376
7377    // Delete should operate on the selected entry (file1.txt)
7378    submit_deletion(&panel, cx);
7379    assert_eq!(
7380        visible_entries_as_strings(&panel, 0..15, cx),
7381        &[
7382            "v root",
7383            "    v dir1",
7384            "          file2.txt  <== selected  <== marked",
7385            "          file3.txt",
7386            "    v dir2",
7387            "          file4.txt",
7388            "          file5.txt",
7389        ],
7390        "Should delete selected file, not marked file"
7391    );
7392
7393    select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
7394    select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
7395    select_path(&panel, "root/dir2/file5.txt", cx);
7396
7397    assert_eq!(
7398        visible_entries_as_strings(&panel, 0..15, cx),
7399        &[
7400            "v root",
7401            "    v dir1",
7402            "          file2.txt  <== marked",
7403            "          file3.txt  <== marked",
7404            "    v dir2",
7405            "          file4.txt  <== marked",
7406            "          file5.txt  <== selected",
7407        ],
7408        "Initial state with multiple marked entries and different selection"
7409    );
7410
7411    // Delete should operate on all marked entries, ignoring the selection
7412    submit_deletion(&panel, cx);
7413    assert_eq!(
7414        visible_entries_as_strings(&panel, 0..15, cx),
7415        &[
7416            "v root",
7417            "    v dir1",
7418            "    v dir2",
7419            "          file5.txt  <== selected",
7420        ],
7421        "Should delete all marked files, leaving only the selected file"
7422    );
7423}
7424
7425#[gpui::test]
7426async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7427    init_test_with_editor(cx);
7428
7429    let fs = FakeFs::new(cx.executor());
7430    fs.insert_tree(
7431        "/root_b",
7432        json!({
7433            "dir1": {
7434                "file1.txt": "content 1",
7435                "file2.txt": "content 2",
7436            },
7437        }),
7438    )
7439    .await;
7440
7441    fs.insert_tree(
7442        "/root_c",
7443        json!({
7444            "dir2": {},
7445        }),
7446    )
7447    .await;
7448
7449    let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7450    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7451    let workspace = window
7452        .read_with(cx, |mw, _| mw.workspace().clone())
7453        .unwrap();
7454    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7455    let panel = workspace.update_in(cx, ProjectPanel::new);
7456    cx.run_until_parked();
7457
7458    toggle_expand_dir(&panel, "root_b/dir1", cx);
7459    toggle_expand_dir(&panel, "root_c/dir2", cx);
7460
7461    cx.simulate_modifiers_change(gpui::Modifiers {
7462        control: true,
7463        ..Default::default()
7464    });
7465    select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7466    select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7467
7468    assert_eq!(
7469        visible_entries_as_strings(&panel, 0..20, cx),
7470        &[
7471            "v root_b",
7472            "    v dir1",
7473            "          file1.txt  <== marked",
7474            "          file2.txt  <== selected  <== marked",
7475            "v root_c",
7476            "    v dir2",
7477        ],
7478        "Initial state with files marked in root_b"
7479    );
7480
7481    submit_deletion(&panel, cx);
7482    assert_eq!(
7483        visible_entries_as_strings(&panel, 0..20, cx),
7484        &[
7485            "v root_b",
7486            "    v dir1  <== selected",
7487            "v root_c",
7488            "    v dir2",
7489        ],
7490        "After deletion in root_b as it's last deletion, selection should be in root_b"
7491    );
7492
7493    select_path_with_mark(&panel, "root_c/dir2", cx);
7494
7495    submit_deletion(&panel, cx);
7496    assert_eq!(
7497        visible_entries_as_strings(&panel, 0..20, cx),
7498        &["v root_b", "    v dir1", "v root_c  <== selected",],
7499        "After deleting from root_c, it should remain in root_c"
7500    );
7501}
7502
7503pub(crate) fn toggle_expand_dir(
7504    panel: &Entity<ProjectPanel>,
7505    path: &str,
7506    cx: &mut VisualTestContext,
7507) {
7508    let path = rel_path(path);
7509    panel.update_in(cx, |panel, window, cx| {
7510        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7511            let worktree = worktree.read(cx);
7512            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7513                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7514                panel.toggle_expanded(entry_id, window, cx);
7515                return;
7516            }
7517        }
7518        panic!("no worktree for path {:?}", path);
7519    });
7520    cx.run_until_parked();
7521}
7522
7523#[gpui::test]
7524async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
7525    init_test_with_editor(cx);
7526
7527    let fs = FakeFs::new(cx.executor());
7528    fs.insert_tree(
7529        path!("/root"),
7530        json!({
7531            ".gitignore": "**/ignored_dir\n**/ignored_nested",
7532            "dir1": {
7533                "empty1": {
7534                    "empty2": {
7535                        "empty3": {
7536                            "file.txt": ""
7537                        }
7538                    }
7539                },
7540                "subdir1": {
7541                    "file1.txt": "",
7542                    "file2.txt": "",
7543                    "ignored_nested": {
7544                        "ignored_file.txt": ""
7545                    }
7546                },
7547                "ignored_dir": {
7548                    "subdir": {
7549                        "deep_file.txt": ""
7550                    }
7551                }
7552            }
7553        }),
7554    )
7555    .await;
7556
7557    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7558    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7559    let workspace = window
7560        .read_with(cx, |mw, _| mw.workspace().clone())
7561        .unwrap();
7562    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7563
7564    // Test 1: When auto-fold is enabled
7565    cx.update(|_, cx| {
7566        let settings = *ProjectPanelSettings::get_global(cx);
7567        ProjectPanelSettings::override_global(
7568            ProjectPanelSettings {
7569                auto_fold_dirs: true,
7570                ..settings
7571            },
7572            cx,
7573        );
7574    });
7575
7576    let panel = workspace.update_in(cx, ProjectPanel::new);
7577    cx.run_until_parked();
7578
7579    assert_eq!(
7580        visible_entries_as_strings(&panel, 0..20, cx),
7581        &["v root", "    > dir1", "      .gitignore",],
7582        "Initial state should show collapsed root structure"
7583    );
7584
7585    toggle_expand_dir(&panel, "root/dir1", cx);
7586    assert_eq!(
7587        visible_entries_as_strings(&panel, 0..20, cx),
7588        &[
7589            "v root",
7590            "    v dir1  <== selected",
7591            "        > empty1/empty2/empty3",
7592            "        > ignored_dir",
7593            "        > subdir1",
7594            "      .gitignore",
7595        ],
7596        "Should show first level with auto-folded dirs and ignored dir visible"
7597    );
7598
7599    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7600    panel.update_in(cx, |panel, window, cx| {
7601        let project = panel.project.read(cx);
7602        let worktree = project.worktrees(cx).next().unwrap().read(cx);
7603        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
7604        panel.update_visible_entries(None, false, false, window, cx);
7605    });
7606    cx.run_until_parked();
7607
7608    assert_eq!(
7609        visible_entries_as_strings(&panel, 0..20, cx),
7610        &[
7611            "v root",
7612            "    v dir1  <== selected",
7613            "        v empty1",
7614            "            v empty2",
7615            "                v empty3",
7616            "                      file.txt",
7617            "        > ignored_dir",
7618            "        v subdir1",
7619            "            > ignored_nested",
7620            "              file1.txt",
7621            "              file2.txt",
7622            "      .gitignore",
7623        ],
7624        "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
7625    );
7626
7627    // Test 2: When auto-fold is disabled
7628    cx.update(|_, cx| {
7629        let settings = *ProjectPanelSettings::get_global(cx);
7630        ProjectPanelSettings::override_global(
7631            ProjectPanelSettings {
7632                auto_fold_dirs: false,
7633                ..settings
7634            },
7635            cx,
7636        );
7637    });
7638
7639    panel.update_in(cx, |panel, window, cx| {
7640        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
7641    });
7642
7643    toggle_expand_dir(&panel, "root/dir1", cx);
7644    assert_eq!(
7645        visible_entries_as_strings(&panel, 0..20, cx),
7646        &[
7647            "v root",
7648            "    v dir1  <== selected",
7649            "        > empty1",
7650            "        > ignored_dir",
7651            "        > subdir1",
7652            "      .gitignore",
7653        ],
7654        "With auto-fold disabled: should show all directories separately"
7655    );
7656
7657    let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7658    panel.update_in(cx, |panel, window, cx| {
7659        let project = panel.project.read(cx);
7660        let worktree = project.worktrees(cx).next().unwrap().read(cx);
7661        panel.expand_all_for_entry(worktree.id(), entry_id, cx);
7662        panel.update_visible_entries(None, false, false, window, cx);
7663    });
7664    cx.run_until_parked();
7665
7666    assert_eq!(
7667        visible_entries_as_strings(&panel, 0..20, cx),
7668        &[
7669            "v root",
7670            "    v dir1  <== selected",
7671            "        v empty1",
7672            "            v empty2",
7673            "                v empty3",
7674            "                      file.txt",
7675            "        > ignored_dir",
7676            "        v subdir1",
7677            "            > ignored_nested",
7678            "              file1.txt",
7679            "              file2.txt",
7680            "      .gitignore",
7681        ],
7682        "After expand_all without auto-fold: should expand all dirs normally, \
7683         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
7684    );
7685
7686    // Test 3: When explicitly called on ignored directory
7687    let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
7688    panel.update_in(cx, |panel, window, cx| {
7689        let project = panel.project.read(cx);
7690        let worktree = project.worktrees(cx).next().unwrap().read(cx);
7691        panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
7692        panel.update_visible_entries(None, false, false, window, cx);
7693    });
7694    cx.run_until_parked();
7695
7696    assert_eq!(
7697        visible_entries_as_strings(&panel, 0..20, cx),
7698        &[
7699            "v root",
7700            "    v dir1  <== selected",
7701            "        v empty1",
7702            "            v empty2",
7703            "                v empty3",
7704            "                      file.txt",
7705            "        v ignored_dir",
7706            "            v subdir",
7707            "                  deep_file.txt",
7708            "        v subdir1",
7709            "            > ignored_nested",
7710            "              file1.txt",
7711            "              file2.txt",
7712            "      .gitignore",
7713        ],
7714        "After expand_all on ignored_dir: should expand all contents of the ignored directory"
7715    );
7716}
7717
7718#[gpui::test]
7719async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
7720    init_test(cx);
7721
7722    let fs = FakeFs::new(cx.executor());
7723    fs.insert_tree(
7724        path!("/root"),
7725        json!({
7726            "dir1": {
7727                "subdir1": {
7728                    "nested1": {
7729                        "file1.txt": "",
7730                        "file2.txt": ""
7731                    },
7732                },
7733                "subdir2": {
7734                    "file4.txt": ""
7735                }
7736            },
7737            "dir2": {
7738                "single_file": {
7739                    "file5.txt": ""
7740                }
7741            }
7742        }),
7743    )
7744    .await;
7745
7746    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7747    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7748    let workspace = window
7749        .read_with(cx, |mw, _| mw.workspace().clone())
7750        .unwrap();
7751    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7752
7753    // Test 1: Basic collapsing
7754    {
7755        let panel = workspace.update_in(cx, ProjectPanel::new);
7756        cx.run_until_parked();
7757
7758        toggle_expand_dir(&panel, "root/dir1", cx);
7759        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7760        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7761        toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7762
7763        assert_eq!(
7764            visible_entries_as_strings(&panel, 0..20, cx),
7765            &[
7766                "v root",
7767                "    v dir1",
7768                "        v subdir1",
7769                "            v nested1",
7770                "                  file1.txt",
7771                "                  file2.txt",
7772                "        v subdir2  <== selected",
7773                "              file4.txt",
7774                "    > dir2",
7775            ],
7776            "Initial state with everything expanded"
7777        );
7778
7779        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7780        panel.update_in(cx, |panel, window, cx| {
7781            let project = panel.project.read(cx);
7782            let worktree = project.worktrees(cx).next().unwrap().read(cx);
7783            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7784            panel.update_visible_entries(None, false, false, window, cx);
7785        });
7786        cx.run_until_parked();
7787
7788        assert_eq!(
7789            visible_entries_as_strings(&panel, 0..20, cx),
7790            &["v root", "    > dir1", "    > dir2",],
7791            "All subdirs under dir1 should be collapsed"
7792        );
7793    }
7794
7795    // Test 2: With auto-fold enabled
7796    {
7797        cx.update(|_, cx| {
7798            let settings = *ProjectPanelSettings::get_global(cx);
7799            ProjectPanelSettings::override_global(
7800                ProjectPanelSettings {
7801                    auto_fold_dirs: true,
7802                    ..settings
7803                },
7804                cx,
7805            );
7806        });
7807
7808        let panel = workspace.update_in(cx, ProjectPanel::new);
7809        cx.run_until_parked();
7810
7811        toggle_expand_dir(&panel, "root/dir1", cx);
7812        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7813        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7814
7815        assert_eq!(
7816            visible_entries_as_strings(&panel, 0..20, cx),
7817            &[
7818                "v root",
7819                "    v dir1",
7820                "        v subdir1/nested1  <== selected",
7821                "              file1.txt",
7822                "              file2.txt",
7823                "        > subdir2",
7824                "    > dir2/single_file",
7825            ],
7826            "Initial state with some dirs expanded"
7827        );
7828
7829        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7830        panel.update(cx, |panel, cx| {
7831            let project = panel.project.read(cx);
7832            let worktree = project.worktrees(cx).next().unwrap().read(cx);
7833            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7834        });
7835
7836        toggle_expand_dir(&panel, "root/dir1", cx);
7837
7838        assert_eq!(
7839            visible_entries_as_strings(&panel, 0..20, cx),
7840            &[
7841                "v root",
7842                "    v dir1  <== selected",
7843                "        > subdir1/nested1",
7844                "        > subdir2",
7845                "    > dir2/single_file",
7846            ],
7847            "Subdirs should be collapsed and folded with auto-fold enabled"
7848        );
7849    }
7850
7851    // Test 3: With auto-fold disabled
7852    {
7853        cx.update(|_, cx| {
7854            let settings = *ProjectPanelSettings::get_global(cx);
7855            ProjectPanelSettings::override_global(
7856                ProjectPanelSettings {
7857                    auto_fold_dirs: false,
7858                    ..settings
7859                },
7860                cx,
7861            );
7862        });
7863
7864        let panel = workspace.update_in(cx, ProjectPanel::new);
7865        cx.run_until_parked();
7866
7867        toggle_expand_dir(&panel, "root/dir1", cx);
7868        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7869        toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7870
7871        assert_eq!(
7872            visible_entries_as_strings(&panel, 0..20, cx),
7873            &[
7874                "v root",
7875                "    v dir1",
7876                "        v subdir1",
7877                "            v nested1  <== selected",
7878                "                  file1.txt",
7879                "                  file2.txt",
7880                "        > subdir2",
7881                "    > dir2",
7882            ],
7883            "Initial state with some dirs expanded and auto-fold disabled"
7884        );
7885
7886        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7887        panel.update(cx, |panel, cx| {
7888            let project = panel.project.read(cx);
7889            let worktree = project.worktrees(cx).next().unwrap().read(cx);
7890            panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7891        });
7892
7893        toggle_expand_dir(&panel, "root/dir1", cx);
7894
7895        assert_eq!(
7896            visible_entries_as_strings(&panel, 0..20, cx),
7897            &[
7898                "v root",
7899                "    v dir1  <== selected",
7900                "        > subdir1",
7901                "        > subdir2",
7902                "    > dir2",
7903            ],
7904            "Subdirs should be collapsed but not folded with auto-fold disabled"
7905        );
7906    }
7907}
7908
7909#[gpui::test]
7910async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
7911    init_test(cx);
7912
7913    let fs = FakeFs::new(cx.executor());
7914    fs.insert_tree(
7915        path!("/root"),
7916        json!({
7917            "dir1": {
7918                "subdir1": {
7919                    "nested1": {
7920                        "file1.txt": "",
7921                        "file2.txt": ""
7922                    },
7923                },
7924                "subdir2": {
7925                    "file3.txt": ""
7926                }
7927            },
7928            "dir2": {
7929                "file4.txt": ""
7930            }
7931        }),
7932    )
7933    .await;
7934
7935    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7936    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7937    let workspace = window
7938        .read_with(cx, |mw, _| mw.workspace().clone())
7939        .unwrap();
7940    let cx = &mut VisualTestContext::from_window(window.into(), cx);
7941
7942    let panel = workspace.update_in(cx, ProjectPanel::new);
7943    cx.run_until_parked();
7944
7945    toggle_expand_dir(&panel, "root/dir1", cx);
7946    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7947    toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7948    toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7949    toggle_expand_dir(&panel, "root/dir2", cx);
7950
7951    assert_eq!(
7952        visible_entries_as_strings(&panel, 0..20, cx),
7953        &[
7954            "v root",
7955            "    v dir1",
7956            "        v subdir1",
7957            "            v nested1",
7958            "                  file1.txt",
7959            "                  file2.txt",
7960            "        v subdir2",
7961            "              file3.txt",
7962            "    v dir2  <== selected",
7963            "          file4.txt",
7964        ],
7965        "Initial state with directories expanded"
7966    );
7967
7968    select_path(&panel, "root/dir1", cx);
7969    cx.run_until_parked();
7970
7971    panel.update_in(cx, |panel, window, cx| {
7972        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7973    });
7974    cx.run_until_parked();
7975
7976    assert_eq!(
7977        visible_entries_as_strings(&panel, 0..20, cx),
7978        &[
7979            "v root",
7980            "    > dir1  <== selected",
7981            "    v dir2",
7982            "          file4.txt",
7983        ],
7984        "dir1 and all its children should be collapsed, dir2 should remain expanded"
7985    );
7986
7987    toggle_expand_dir(&panel, "root/dir1", cx);
7988    cx.run_until_parked();
7989
7990    assert_eq!(
7991        visible_entries_as_strings(&panel, 0..20, cx),
7992        &[
7993            "v root",
7994            "    v dir1  <== selected",
7995            "        > subdir1",
7996            "        > subdir2",
7997            "    v dir2",
7998            "          file4.txt",
7999        ],
8000        "After re-expanding dir1, its children should still be collapsed"
8001    );
8002}
8003
8004#[gpui::test]
8005async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
8006    init_test(cx);
8007
8008    let fs = FakeFs::new(cx.executor());
8009    fs.insert_tree(
8010        path!("/root"),
8011        json!({
8012            "dir1": {
8013                "subdir1": {
8014                    "file1.txt": ""
8015                },
8016                "file2.txt": ""
8017            },
8018            "dir2": {
8019                "file3.txt": ""
8020            }
8021        }),
8022    )
8023    .await;
8024
8025    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8026    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8027    let workspace = window
8028        .read_with(cx, |mw, _| mw.workspace().clone())
8029        .unwrap();
8030    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8031
8032    let panel = workspace.update_in(cx, ProjectPanel::new);
8033    cx.run_until_parked();
8034
8035    toggle_expand_dir(&panel, "root/dir1", cx);
8036    toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8037    toggle_expand_dir(&panel, "root/dir2", cx);
8038
8039    assert_eq!(
8040        visible_entries_as_strings(&panel, 0..20, cx),
8041        &[
8042            "v root",
8043            "    v dir1",
8044            "        v subdir1",
8045            "              file1.txt",
8046            "          file2.txt",
8047            "    v dir2  <== selected",
8048            "          file3.txt",
8049        ],
8050        "Initial state with directories expanded"
8051    );
8052
8053    // Select the root and collapse it and its children
8054    select_path(&panel, "root", cx);
8055    cx.run_until_parked();
8056
8057    panel.update_in(cx, |panel, window, cx| {
8058        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
8059    });
8060    cx.run_until_parked();
8061
8062    // The root and all its children should be collapsed
8063    assert_eq!(
8064        visible_entries_as_strings(&panel, 0..20, cx),
8065        &["> root  <== selected"],
8066        "Root and all children should be collapsed"
8067    );
8068
8069    // Re-expand root and dir1, verify children were recursively collapsed
8070    toggle_expand_dir(&panel, "root", cx);
8071    toggle_expand_dir(&panel, "root/dir1", cx);
8072    cx.run_until_parked();
8073
8074    assert_eq!(
8075        visible_entries_as_strings(&panel, 0..20, cx),
8076        &[
8077            "v root",
8078            "    v dir1  <== selected",
8079            "        > subdir1",
8080            "          file2.txt",
8081            "    > dir2",
8082        ],
8083        "After re-expanding root and dir1, subdir1 should still be collapsed"
8084    );
8085}
8086
8087#[gpui::test]
8088async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
8089    init_test(cx);
8090
8091    let fs = FakeFs::new(cx.executor());
8092    fs.insert_tree(
8093        path!("/root1"),
8094        json!({
8095            "dir1": {
8096                "subdir1": {
8097                    "file1.txt": ""
8098                },
8099                "file2.txt": ""
8100            }
8101        }),
8102    )
8103    .await;
8104    fs.insert_tree(
8105        path!("/root2"),
8106        json!({
8107            "dir2": {
8108                "file3.txt": ""
8109            },
8110            "file4.txt": ""
8111        }),
8112    )
8113    .await;
8114
8115    let project = Project::test(
8116        fs.clone(),
8117        [path!("/root1").as_ref(), path!("/root2").as_ref()],
8118        cx,
8119    )
8120    .await;
8121    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8122    let workspace = window
8123        .read_with(cx, |mw, _| mw.workspace().clone())
8124        .unwrap();
8125    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8126
8127    let panel = workspace.update_in(cx, ProjectPanel::new);
8128    cx.run_until_parked();
8129
8130    toggle_expand_dir(&panel, "root1/dir1", cx);
8131    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
8132    toggle_expand_dir(&panel, "root2/dir2", cx);
8133
8134    assert_eq!(
8135        visible_entries_as_strings(&panel, 0..20, cx),
8136        &[
8137            "v root1",
8138            "    v dir1",
8139            "        v subdir1",
8140            "              file1.txt",
8141            "          file2.txt",
8142            "v root2",
8143            "    v dir2  <== selected",
8144            "          file3.txt",
8145            "      file4.txt",
8146        ],
8147        "Initial state with directories expanded across worktrees"
8148    );
8149
8150    // Select root1 and collapse it and its children.
8151    // In a multi-worktree project, this should only collapse the selected worktree,
8152    // leaving other worktrees unaffected.
8153    select_path(&panel, "root1", cx);
8154    cx.run_until_parked();
8155
8156    panel.update_in(cx, |panel, window, cx| {
8157        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
8158    });
8159    cx.run_until_parked();
8160
8161    assert_eq!(
8162        visible_entries_as_strings(&panel, 0..20, cx),
8163        &[
8164            "> root1  <== selected",
8165            "v root2",
8166            "    v dir2",
8167            "          file3.txt",
8168            "      file4.txt",
8169        ],
8170        "Only root1 should be collapsed, root2 should remain expanded"
8171    );
8172
8173    // Re-expand root1 and verify its children were recursively collapsed
8174    toggle_expand_dir(&panel, "root1", cx);
8175
8176    assert_eq!(
8177        visible_entries_as_strings(&panel, 0..20, cx),
8178        &[
8179            "v root1  <== selected",
8180            "    > dir1",
8181            "v root2",
8182            "    v dir2",
8183            "          file3.txt",
8184            "      file4.txt",
8185        ],
8186        "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
8187    );
8188}
8189
8190#[gpui::test]
8191async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
8192    init_test(cx);
8193
8194    let fs = FakeFs::new(cx.executor());
8195    fs.insert_tree(
8196        path!("/root1"),
8197        json!({
8198            "dir1": {
8199                "subdir1": {
8200                    "file1.txt": ""
8201                },
8202                "file2.txt": ""
8203            }
8204        }),
8205    )
8206    .await;
8207    fs.insert_tree(
8208        path!("/root2"),
8209        json!({
8210            "dir2": {
8211                "subdir2": {
8212                    "file3.txt": ""
8213                },
8214                "file4.txt": ""
8215            }
8216        }),
8217    )
8218    .await;
8219
8220    let project = Project::test(
8221        fs.clone(),
8222        [path!("/root1").as_ref(), path!("/root2").as_ref()],
8223        cx,
8224    )
8225    .await;
8226    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8227    let workspace = window
8228        .read_with(cx, |mw, _| mw.workspace().clone())
8229        .unwrap();
8230    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8231
8232    let panel = workspace.update_in(cx, ProjectPanel::new);
8233    cx.run_until_parked();
8234
8235    toggle_expand_dir(&panel, "root1/dir1", cx);
8236    toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
8237    toggle_expand_dir(&panel, "root2/dir2", cx);
8238    toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
8239
8240    assert_eq!(
8241        visible_entries_as_strings(&panel, 0..20, cx),
8242        &[
8243            "v root1",
8244            "    v dir1",
8245            "        v subdir1",
8246            "              file1.txt",
8247            "          file2.txt",
8248            "v root2",
8249            "    v dir2",
8250            "        v subdir2  <== selected",
8251            "              file3.txt",
8252            "          file4.txt",
8253        ],
8254        "Initial state with directories expanded across worktrees"
8255    );
8256
8257    // Select dir1 in root1 and collapse it
8258    select_path(&panel, "root1/dir1", cx);
8259    cx.run_until_parked();
8260
8261    panel.update_in(cx, |panel, window, cx| {
8262        panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
8263    });
8264    cx.run_until_parked();
8265
8266    assert_eq!(
8267        visible_entries_as_strings(&panel, 0..20, cx),
8268        &[
8269            "v root1",
8270            "    > dir1  <== selected",
8271            "v root2",
8272            "    v dir2",
8273            "        v subdir2",
8274            "              file3.txt",
8275            "          file4.txt",
8276        ],
8277        "Only dir1 should be collapsed, root2 should be completely unaffected"
8278    );
8279
8280    // Re-expand dir1 and verify subdir1 was recursively collapsed
8281    toggle_expand_dir(&panel, "root1/dir1", cx);
8282
8283    assert_eq!(
8284        visible_entries_as_strings(&panel, 0..20, cx),
8285        &[
8286            "v root1",
8287            "    v dir1  <== selected",
8288            "        > subdir1",
8289            "          file2.txt",
8290            "v root2",
8291            "    v dir2",
8292            "        v subdir2",
8293            "              file3.txt",
8294            "          file4.txt",
8295        ],
8296        "After re-expanding dir1, subdir1 should still be collapsed"
8297    );
8298}
8299
8300#[gpui::test]
8301async fn test_expand_all_entries(cx: &mut gpui::TestAppContext) {
8302    init_test_with_editor(cx);
8303
8304    let fs = FakeFs::new(cx.executor());
8305    fs.insert_tree(
8306        "/project_root",
8307        json!({
8308            "dir_1": {
8309                "nested_dir": {
8310                    "file_a.py": "# File contents",
8311                    "file_b.py": "# File contents",
8312                    "file_c.py": "# File contents",
8313                },
8314                "file_1.py": "# File contents",
8315                "file_2.py": "# File contents",
8316                "file_3.py": "# File contents",
8317            },
8318            "dir_2": {
8319                "file_1.py": "# File contents",
8320                "file_2.py": "# File contents",
8321                "file_3.py": "# File contents",
8322            }
8323        }),
8324    )
8325    .await;
8326
8327    let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
8328    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8329    let workspace = window
8330        .read_with(cx, |mw, _| mw.workspace().clone())
8331        .unwrap();
8332    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8333    let panel = workspace.update_in(cx, ProjectPanel::new);
8334    cx.run_until_parked();
8335
8336    panel.update_in(cx, |panel, window, cx| {
8337        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
8338    });
8339    cx.executor().run_until_parked();
8340    assert_eq!(
8341        visible_entries_as_strings(&panel, 0..10, cx),
8342        &["v project_root", "    > dir_1", "    > dir_2",]
8343    );
8344
8345    panel.update_in(cx, |panel, window, cx| {
8346        panel.expand_all_entries(&ExpandAllEntries, window, cx)
8347    });
8348    cx.executor().run_until_parked();
8349
8350    let entries = visible_entries_as_strings(&panel, 0..20, cx);
8351    assert_eq!(entries.len(), 13, "should show all 13 entries");
8352    assert!(entries[0].starts_with("v project_root"), "root expanded");
8353    assert!(entries[1].contains("v dir_1"), "dir_1 expanded");
8354    assert!(entries[2].contains("v nested_dir"), "nested_dir expanded");
8355    assert!(
8356        entries.iter().any(|e| e.contains("file_a.py")),
8357        "file_a visible"
8358    );
8359    assert!(
8360        entries.iter().any(|e| e.contains("file_c.py")),
8361        "file_c visible"
8362    );
8363    assert!(
8364        entries.iter().any(|e| e.contains("v dir_2")),
8365        "dir_2 expanded"
8366    );
8367    assert!(
8368        !entries.iter().any(|e| e.contains("> ")),
8369        "no collapsed dirs"
8370    );
8371}
8372
8373#[gpui::test]
8374async fn test_expand_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
8375    init_test_with_editor(cx);
8376
8377    let fs = FakeFs::new(cx.executor());
8378    let worktree_content = json!({
8379        "dir_1": {
8380            "file_1.py": "# File contents",
8381        },
8382        "dir_2": {
8383            "file_1.py": "# File contents",
8384        }
8385    });
8386
8387    fs.insert_tree("/project_root_1", worktree_content.clone())
8388        .await;
8389    fs.insert_tree("/project_root_2", worktree_content).await;
8390
8391    let project = Project::test(
8392        fs.clone(),
8393        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
8394        cx,
8395    )
8396    .await;
8397    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8398    let workspace = window
8399        .read_with(cx, |mw, _| mw.workspace().clone())
8400        .unwrap();
8401    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8402    let panel = workspace.update_in(cx, ProjectPanel::new);
8403    cx.run_until_parked();
8404
8405    panel.update_in(cx, |panel, window, cx| {
8406        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
8407    });
8408    cx.executor().run_until_parked();
8409    assert_eq!(
8410        visible_entries_as_strings(&panel, 0..10, cx),
8411        &["> project_root_1", "> project_root_2",]
8412    );
8413
8414    panel.update_in(cx, |panel, window, cx| {
8415        panel.expand_all_entries(&ExpandAllEntries, window, cx)
8416    });
8417    cx.executor().run_until_parked();
8418    assert_eq!(
8419        visible_entries_as_strings(&panel, 0..20, cx),
8420        &[
8421            "v project_root_1",
8422            "    v dir_1",
8423            "          file_1.py",
8424            "    v dir_2",
8425            "          file_1.py",
8426            "v project_root_2",
8427            "    v dir_1",
8428            "          file_1.py",
8429            "    v dir_2",
8430            "          file_1.py",
8431        ]
8432    );
8433}
8434
8435#[gpui::test]
8436async fn test_expand_all_entries_via_window_dispatch(cx: &mut gpui::TestAppContext) {
8437    init_test_with_editor(cx);
8438
8439    let fs = FakeFs::new(cx.executor());
8440    let worktree_content = json!({
8441        "dir_1": {
8442            "file_1.py": "# File contents",
8443        },
8444        "dir_2": {
8445            "file_1.py": "# File contents",
8446        }
8447    });
8448
8449    fs.insert_tree("/project_root_1", worktree_content.clone())
8450        .await;
8451    fs.insert_tree("/project_root_2", worktree_content).await;
8452
8453    let project = Project::test(
8454        fs.clone(),
8455        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
8456        cx,
8457    )
8458    .await;
8459    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8460    let workspace = window
8461        .read_with(cx, |mw, _| mw.workspace().clone())
8462        .unwrap();
8463    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8464    cx.update(|_, cx| {
8465        let settings = *ProjectPanelSettings::get_global(cx);
8466        ProjectPanelSettings::override_global(
8467            ProjectPanelSettings {
8468                auto_reveal_entries: false,
8469                ..settings
8470            },
8471            cx,
8472        );
8473    });
8474    let panel = workspace.update_in(cx, |workspace, window, cx| {
8475        let panel = ProjectPanel::new(workspace, window, cx);
8476        workspace.add_panel(panel.clone(), window, cx);
8477        panel
8478    });
8479    cx.run_until_parked();
8480
8481    panel.update_in(cx, |panel, window, cx| {
8482        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
8483    });
8484    cx.executor().run_until_parked();
8485    assert_eq!(
8486        visible_entries_as_strings(&panel, 0..10, cx),
8487        &["> project_root_1", "> project_root_2",]
8488    );
8489
8490    panel.update_in(cx, |panel, window, cx| {
8491        panel.focus_handle(cx).focus(window, cx);
8492    });
8493    cx.dispatch_action(ExpandAllEntries);
8494    cx.executor().run_until_parked();
8495    assert_eq!(
8496        visible_entries_as_strings(&panel, 0..20, cx),
8497        &[
8498            "v project_root_1",
8499            "    v dir_1",
8500            "          file_1.py",
8501            "    v dir_2",
8502            "          file_1.py",
8503            "v project_root_2",
8504            "    v dir_1",
8505            "          file_1.py",
8506            "    v dir_2",
8507            "          file_1.py",
8508        ]
8509    );
8510}
8511
8512#[gpui::test]
8513async fn test_expand_all_for_entry_single_worktree(cx: &mut gpui::TestAppContext) {
8514    init_test_with_editor(cx);
8515
8516    let fs = FakeFs::new(cx.executor());
8517    let worktree_content = json!({
8518        "dir_1": {
8519            "file_1.py": "# File contents",
8520        },
8521        "dir_2": {
8522            "file_1.py": "# File contents",
8523        }
8524    });
8525
8526    fs.insert_tree("/project_root_1", worktree_content.clone())
8527        .await;
8528    fs.insert_tree("/project_root_2", worktree_content).await;
8529
8530    let project = Project::test(
8531        fs.clone(),
8532        ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
8533        cx,
8534    )
8535    .await;
8536    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8537    let workspace = window
8538        .read_with(cx, |mw, _| mw.workspace().clone())
8539        .unwrap();
8540    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8541    let panel = workspace.update_in(cx, ProjectPanel::new);
8542    cx.run_until_parked();
8543
8544    panel.update_in(cx, |panel, window, cx| {
8545        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
8546    });
8547    cx.executor().run_until_parked();
8548    assert_eq!(
8549        visible_entries_as_strings(&panel, 0..10, cx),
8550        &["> project_root_1", "> project_root_2",]
8551    );
8552
8553    let root2_entry = find_project_entry(&panel, "project_root_2", cx).unwrap();
8554    panel.update_in(cx, |panel, window, cx| {
8555        let worktree_id = panel
8556            .project
8557            .read(cx)
8558            .worktree_id_for_entry(root2_entry, cx)
8559            .unwrap();
8560        panel.expand_all_for_entry(worktree_id, root2_entry, cx);
8561        panel.update_visible_entries(None, false, false, window, cx);
8562    });
8563    cx.executor().run_until_parked();
8564    assert_eq!(
8565        visible_entries_as_strings(&panel, 0..20, cx),
8566        &[
8567            "> project_root_1",
8568            "v project_root_2",
8569            "    v dir_1",
8570            "          file_1.py",
8571            "    v dir_2",
8572            "          file_1.py",
8573        ]
8574    );
8575}
8576
8577#[gpui::test]
8578async fn test_expand_all_entries_with_auto_fold(cx: &mut gpui::TestAppContext) {
8579    init_test_with_editor(cx);
8580
8581    let fs = FakeFs::new(cx.executor());
8582    fs.insert_tree(
8583        path!("/root"),
8584        json!({
8585            "dir1": {
8586                "empty1": {
8587                    "empty2": {
8588                        "empty3": {
8589                            "file.txt": ""
8590                        }
8591                    }
8592                },
8593            }
8594        }),
8595    )
8596    .await;
8597
8598    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8599    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8600    let workspace = window
8601        .read_with(cx, |mw, _| mw.workspace().clone())
8602        .unwrap();
8603    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8604
8605    cx.update(|_, cx| {
8606        let settings = *ProjectPanelSettings::get_global(cx);
8607        ProjectPanelSettings::override_global(
8608            ProjectPanelSettings {
8609                auto_fold_dirs: true,
8610                ..settings
8611            },
8612            cx,
8613        );
8614    });
8615
8616    let panel = workspace.update_in(cx, ProjectPanel::new);
8617    cx.run_until_parked();
8618
8619    panel.update_in(cx, |panel, window, cx| {
8620        panel.collapse_all_entries(&CollapseAllEntries, window, cx)
8621    });
8622    cx.executor().run_until_parked();
8623
8624    panel.update_in(cx, |panel, window, cx| {
8625        panel.expand_all_entries(&ExpandAllEntries, window, cx)
8626    });
8627    cx.executor().run_until_parked();
8628
8629    assert_eq!(
8630        visible_entries_as_strings(&panel, 0..20, cx),
8631        &[
8632            "v root",
8633            "    v dir1",
8634            "        v empty1",
8635            "            v empty2",
8636            "                v empty3",
8637            "                      file.txt",
8638        ],
8639        "expand all should unfold auto-folded directories"
8640    );
8641}
8642
8643#[gpui::test]
8644async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
8645    init_test(cx);
8646
8647    let fs = FakeFs::new(cx.executor());
8648    fs.insert_tree(
8649        path!("/root"),
8650        json!({
8651            "dir1": {
8652                "file1.txt": "",
8653            },
8654        }),
8655    )
8656    .await;
8657
8658    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8659    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8660    let workspace = window
8661        .read_with(cx, |mw, _| mw.workspace().clone())
8662        .unwrap();
8663    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8664
8665    let panel = workspace.update_in(cx, |workspace, window, cx| {
8666        let panel = ProjectPanel::new(workspace, window, cx);
8667        workspace.add_panel(panel.clone(), window, cx);
8668        panel
8669    });
8670    cx.run_until_parked();
8671
8672    #[rustfmt::skip]
8673    assert_eq!(
8674        visible_entries_as_strings(&panel, 0..20, cx),
8675        &[
8676            "v root",
8677            "    > dir1",
8678        ],
8679        "Initial state with nothing selected"
8680    );
8681
8682    panel.update_in(cx, |panel, window, cx| {
8683        panel.new_file(&NewFile, window, cx);
8684    });
8685    cx.run_until_parked();
8686    panel.update_in(cx, |panel, window, cx| {
8687        assert!(panel.filename_editor.read(cx).is_focused(window));
8688    });
8689    panel
8690        .update_in(cx, |panel, window, cx| {
8691            panel.filename_editor.update(cx, |editor, cx| {
8692                editor.set_text("hello_from_no_selections", window, cx)
8693            });
8694            panel.confirm_edit(true, window, cx).unwrap()
8695        })
8696        .await
8697        .unwrap();
8698    cx.run_until_parked();
8699    #[rustfmt::skip]
8700    assert_eq!(
8701        visible_entries_as_strings(&panel, 0..20, cx),
8702        &[
8703            "v root",
8704            "    > dir1",
8705            "      hello_from_no_selections  <== selected  <== marked",
8706        ],
8707        "A new file is created under the root directory"
8708    );
8709}
8710
8711#[gpui::test]
8712async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
8713    init_test(cx);
8714
8715    let fs = FakeFs::new(cx.executor());
8716    fs.insert_tree(
8717        path!("/root"),
8718        json!({
8719            "existing_dir": {
8720                "existing_file.txt": "",
8721            },
8722            "existing_file.txt": "",
8723        }),
8724    )
8725    .await;
8726
8727    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8728    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8729    let workspace = window
8730        .read_with(cx, |mw, _| mw.workspace().clone())
8731        .unwrap();
8732    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8733
8734    cx.update(|_, cx| {
8735        let settings = *ProjectPanelSettings::get_global(cx);
8736        ProjectPanelSettings::override_global(
8737            ProjectPanelSettings {
8738                hide_root: true,
8739                ..settings
8740            },
8741            cx,
8742        );
8743    });
8744
8745    let panel = workspace.update_in(cx, |workspace, window, cx| {
8746        let panel = ProjectPanel::new(workspace, window, cx);
8747        workspace.add_panel(panel.clone(), window, cx);
8748        panel
8749    });
8750    cx.run_until_parked();
8751
8752    #[rustfmt::skip]
8753    assert_eq!(
8754        visible_entries_as_strings(&panel, 0..20, cx),
8755        &[
8756            "> existing_dir",
8757            "  existing_file.txt",
8758        ],
8759        "Initial state with hide_root=true, root should be hidden and nothing selected"
8760    );
8761
8762    panel.update(cx, |panel, _| {
8763        assert!(
8764            panel.selection.is_none(),
8765            "Should have no selection initially"
8766        );
8767    });
8768
8769    // Test 1: Create new file when no entry is selected
8770    panel.update_in(cx, |panel, window, cx| {
8771        panel.new_file(&NewFile, window, cx);
8772    });
8773    cx.run_until_parked();
8774    panel.update_in(cx, |panel, window, cx| {
8775        assert!(panel.filename_editor.read(cx).is_focused(window));
8776    });
8777    cx.run_until_parked();
8778    #[rustfmt::skip]
8779    assert_eq!(
8780        visible_entries_as_strings(&panel, 0..20, cx),
8781        &[
8782            "> existing_dir",
8783            "  [EDITOR: '']  <== selected",
8784            "  existing_file.txt",
8785        ],
8786        "Editor should appear at root level when hide_root=true and no selection"
8787    );
8788
8789    let confirm = panel.update_in(cx, |panel, window, cx| {
8790        panel.filename_editor.update(cx, |editor, cx| {
8791            editor.set_text("new_file_at_root.txt", window, cx)
8792        });
8793        panel.confirm_edit(true, window, cx).unwrap()
8794    });
8795    confirm.await.unwrap();
8796    cx.run_until_parked();
8797
8798    #[rustfmt::skip]
8799    assert_eq!(
8800        visible_entries_as_strings(&panel, 0..20, cx),
8801        &[
8802            "> existing_dir",
8803            "  existing_file.txt",
8804            "  new_file_at_root.txt  <== selected  <== marked",
8805        ],
8806        "New file should be created at root level and visible without root prefix"
8807    );
8808
8809    assert!(
8810        fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
8811        "File should be created in the actual root directory"
8812    );
8813
8814    // Test 2: Create new directory when no entry is selected
8815    panel.update(cx, |panel, _| {
8816        panel.selection = None;
8817    });
8818
8819    panel.update_in(cx, |panel, window, cx| {
8820        panel.new_directory(&NewDirectory, window, cx);
8821    });
8822    cx.run_until_parked();
8823
8824    panel.update_in(cx, |panel, window, cx| {
8825        assert!(panel.filename_editor.read(cx).is_focused(window));
8826    });
8827
8828    #[rustfmt::skip]
8829    assert_eq!(
8830        visible_entries_as_strings(&panel, 0..20, cx),
8831        &[
8832            "> [EDITOR: '']  <== selected",
8833            "> existing_dir",
8834            "  existing_file.txt",
8835            "  new_file_at_root.txt",
8836        ],
8837        "Directory editor should appear at root level when hide_root=true and no selection"
8838    );
8839
8840    let confirm = panel.update_in(cx, |panel, window, cx| {
8841        panel.filename_editor.update(cx, |editor, cx| {
8842            editor.set_text("new_dir_at_root", window, cx)
8843        });
8844        panel.confirm_edit(true, window, cx).unwrap()
8845    });
8846    confirm.await.unwrap();
8847    cx.run_until_parked();
8848
8849    #[rustfmt::skip]
8850    assert_eq!(
8851        visible_entries_as_strings(&panel, 0..20, cx),
8852        &[
8853            "> existing_dir",
8854            "v new_dir_at_root  <== selected",
8855            "  existing_file.txt",
8856            "  new_file_at_root.txt",
8857        ],
8858        "New directory should be created at root level and visible without root prefix"
8859    );
8860
8861    assert!(
8862        fs.is_dir(Path::new("/root/new_dir_at_root")).await,
8863        "Directory should be created in the actual root directory"
8864    );
8865}
8866
8867#[gpui::test]
8868async fn test_context_menu_new_file_in_empty_hidden_root(cx: &mut gpui::TestAppContext) {
8869    init_test(cx);
8870
8871    let fs = FakeFs::new(cx.executor());
8872    fs.insert_tree(path!("/root"), json!({})).await;
8873
8874    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8875    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8876    let workspace = window
8877        .read_with(cx, |mw, _| mw.workspace().clone())
8878        .unwrap();
8879    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8880
8881    cx.update(|_, cx| {
8882        let settings = *ProjectPanelSettings::get_global(cx);
8883        ProjectPanelSettings::override_global(
8884            ProjectPanelSettings {
8885                hide_root: true,
8886                ..settings
8887            },
8888            cx,
8889        );
8890    });
8891
8892    let panel = workspace.update_in(cx, |workspace, window, cx| {
8893        let panel = ProjectPanel::new(workspace, window, cx);
8894        workspace.add_panel(panel.clone(), window, cx);
8895        panel
8896    });
8897    cx.run_until_parked();
8898
8899    assert!(
8900        visible_entries_as_strings(&panel, 0..20, cx).is_empty(),
8901        "Empty worktree with hide_root=true should render no entries"
8902    );
8903
8904    panel.update(cx, |panel, _| {
8905        assert!(
8906            panel.selection.is_none(),
8907            "Project panel should start without a selection"
8908        );
8909        assert!(
8910            panel.state.last_worktree_root_id.is_some(),
8911            "Project panel should still track the hidden root entry"
8912        );
8913    });
8914
8915    panel.update_in(cx, |panel, window, cx| {
8916        let root_entry_id = panel
8917            .state
8918            .last_worktree_root_id
8919            .expect("hidden root should be available for background context menu actions");
8920        panel.deploy_context_menu(
8921            gpui::point(gpui::px(1.), gpui::px(1.)),
8922            root_entry_id,
8923            window,
8924            cx,
8925        );
8926        panel.new_file(&NewFile, window, cx);
8927    });
8928    cx.run_until_parked();
8929
8930    panel.update_in(cx, |panel, window, cx| {
8931        assert!(
8932            panel.filename_editor.read(cx).is_focused(window),
8933            "New File from the background context menu should open the filename editor"
8934        );
8935    });
8936
8937    assert_eq!(
8938        visible_entries_as_strings(&panel, 0..20, cx),
8939        &["  [EDITOR: '']  <== selected"],
8940        "New file editor should appear at the hidden root level"
8941    );
8942
8943    let confirm = panel.update_in(cx, |panel, window, cx| {
8944        panel.filename_editor.update(cx, |editor, cx| {
8945            editor.set_text("new_file_from_context_menu.txt", window, cx)
8946        });
8947        panel.confirm_edit(true, window, cx).unwrap()
8948    });
8949    confirm.await.unwrap();
8950    cx.run_until_parked();
8951
8952    assert_eq!(
8953        visible_entries_as_strings(&panel, 0..20, cx),
8954        &["  new_file_from_context_menu.txt  <== selected  <== marked"],
8955        "Confirmed file should appear at the hidden root level"
8956    );
8957
8958    assert!(
8959        fs.is_file(Path::new("/root/new_file_from_context_menu.txt"))
8960            .await,
8961        "File should be created in the empty root directory"
8962    );
8963}
8964
8965#[cfg(windows)]
8966#[gpui::test]
8967async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
8968    init_test(cx);
8969
8970    let fs = FakeFs::new(cx.executor());
8971    fs.insert_tree(
8972        path!("/root"),
8973        json!({
8974            "dir1": {
8975                "file1.txt": "",
8976            },
8977        }),
8978    )
8979    .await;
8980
8981    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8982    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8983    let workspace = window
8984        .read_with(cx, |mw, _| mw.workspace().clone())
8985        .unwrap();
8986    let cx = &mut VisualTestContext::from_window(window.into(), cx);
8987
8988    let panel = workspace.update_in(cx, |workspace, window, cx| {
8989        let panel = ProjectPanel::new(workspace, window, cx);
8990        workspace.add_panel(panel.clone(), window, cx);
8991        panel
8992    });
8993    cx.run_until_parked();
8994
8995    #[rustfmt::skip]
8996    assert_eq!(
8997        visible_entries_as_strings(&panel, 0..20, cx),
8998        &[
8999            "v root",
9000            "    > dir1",
9001        ],
9002        "Initial state with nothing selected"
9003    );
9004
9005    panel.update_in(cx, |panel, window, cx| {
9006        panel.new_file(&NewFile, window, cx);
9007    });
9008    cx.run_until_parked();
9009    panel.update_in(cx, |panel, window, cx| {
9010        assert!(panel.filename_editor.read(cx).is_focused(window));
9011    });
9012    panel
9013        .update_in(cx, |panel, window, cx| {
9014            panel
9015                .filename_editor
9016                .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
9017            panel.confirm_edit(true, window, cx).unwrap()
9018        })
9019        .await
9020        .unwrap();
9021    cx.run_until_parked();
9022    #[rustfmt::skip]
9023    assert_eq!(
9024        visible_entries_as_strings(&panel, 0..20, cx),
9025        &[
9026            "v root",
9027            "    > dir1",
9028            "      foo  <== selected  <== marked",
9029        ],
9030        "A new file is created under the root directory without the trailing dot"
9031    );
9032}
9033
9034#[gpui::test]
9035async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
9036    init_test(cx);
9037
9038    let fs = FakeFs::new(cx.executor());
9039    fs.insert_tree(
9040        "/root",
9041        json!({
9042            "dir1": {
9043                "file1.txt": "",
9044                "dir2": {
9045                    "file2.txt": ""
9046                }
9047            },
9048            "file3.txt": ""
9049        }),
9050    )
9051    .await;
9052
9053    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9054    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9055    let workspace = window
9056        .read_with(cx, |mw, _| mw.workspace().clone())
9057        .unwrap();
9058    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9059    let panel = workspace.update_in(cx, ProjectPanel::new);
9060    cx.run_until_parked();
9061
9062    panel.update(cx, |panel, cx| {
9063        let project = panel.project.read(cx);
9064        let worktree = project.visible_worktrees(cx).next().unwrap();
9065        let worktree = worktree.read(cx);
9066
9067        // Test 1: Target is a directory, should highlight the directory itself
9068        let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
9069        let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
9070        assert_eq!(
9071            result,
9072            Some(dir_entry.id),
9073            "Should highlight directory itself"
9074        );
9075
9076        // Test 2: Target is nested file, should highlight immediate parent
9077        let nested_file = worktree
9078            .entry_for_path(rel_path("dir1/dir2/file2.txt"))
9079            .unwrap();
9080        let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
9081        let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
9082        assert_eq!(
9083            result,
9084            Some(nested_parent.id),
9085            "Should highlight immediate parent"
9086        );
9087
9088        // Test 3: Target is root level file, should highlight root
9089        let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
9090        let result = panel.highlight_entry_for_external_drag(root_file, worktree);
9091        assert_eq!(
9092            result,
9093            Some(worktree.root_entry().unwrap().id),
9094            "Root level file should return None"
9095        );
9096
9097        // Test 4: Target is root itself, should highlight root
9098        let root_entry = worktree.root_entry().unwrap();
9099        let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
9100        assert_eq!(
9101            result,
9102            Some(root_entry.id),
9103            "Root level file should return None"
9104        );
9105    });
9106}
9107
9108#[gpui::test]
9109async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
9110    init_test(cx);
9111
9112    let fs = FakeFs::new(cx.executor());
9113    fs.insert_tree(
9114        "/root",
9115        json!({
9116            "parent_dir": {
9117                "child_file.txt": "",
9118                "sibling_file.txt": "",
9119                "child_dir": {
9120                    "nested_file.txt": ""
9121                }
9122            },
9123            "other_dir": {
9124                "other_file.txt": ""
9125            }
9126        }),
9127    )
9128    .await;
9129
9130    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9131    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9132    let workspace = window
9133        .read_with(cx, |mw, _| mw.workspace().clone())
9134        .unwrap();
9135    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9136    let panel = workspace.update_in(cx, ProjectPanel::new);
9137    cx.run_until_parked();
9138
9139    panel.update(cx, |panel, cx| {
9140        let project = panel.project.read(cx);
9141        let worktree = project.visible_worktrees(cx).next().unwrap();
9142        let worktree_id = worktree.read(cx).id();
9143        let worktree = worktree.read(cx);
9144
9145        let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
9146        let child_file = worktree
9147            .entry_for_path(rel_path("parent_dir/child_file.txt"))
9148            .unwrap();
9149        let sibling_file = worktree
9150            .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
9151            .unwrap();
9152        let child_dir = worktree
9153            .entry_for_path(rel_path("parent_dir/child_dir"))
9154            .unwrap();
9155        let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
9156        let other_file = worktree
9157            .entry_for_path(rel_path("other_dir/other_file.txt"))
9158            .unwrap();
9159
9160        // Test 1: Single item drag, don't highlight parent directory
9161        let dragged_selection = DraggedSelection {
9162            active_selection: SelectedEntry {
9163                worktree_id,
9164                entry_id: child_file.id,
9165            },
9166            marked_selections: Arc::new([SelectedEntry {
9167                worktree_id,
9168                entry_id: child_file.id,
9169            }]),
9170        };
9171        let result =
9172            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
9173        assert_eq!(result, None, "Should not highlight parent of dragged item");
9174
9175        // Test 2: Single item drag, don't highlight sibling files
9176        let result = panel.highlight_entry_for_selection_drag(
9177            sibling_file,
9178            worktree,
9179            &dragged_selection,
9180            cx,
9181        );
9182        assert_eq!(result, None, "Should not highlight sibling files");
9183
9184        // Test 3: Single item drag, highlight unrelated directory
9185        let result =
9186            panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
9187        assert_eq!(
9188            result,
9189            Some(other_dir.id),
9190            "Should highlight unrelated directory"
9191        );
9192
9193        // Test 4: Single item drag, highlight sibling directory
9194        let result =
9195            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
9196        assert_eq!(
9197            result,
9198            Some(child_dir.id),
9199            "Should highlight sibling directory"
9200        );
9201
9202        // Test 5: Multiple items drag, highlight parent directory
9203        let dragged_selection = DraggedSelection {
9204            active_selection: SelectedEntry {
9205                worktree_id,
9206                entry_id: child_file.id,
9207            },
9208            marked_selections: Arc::new([
9209                SelectedEntry {
9210                    worktree_id,
9211                    entry_id: child_file.id,
9212                },
9213                SelectedEntry {
9214                    worktree_id,
9215                    entry_id: sibling_file.id,
9216                },
9217            ]),
9218        };
9219        let result =
9220            panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
9221        assert_eq!(
9222            result,
9223            Some(parent_dir.id),
9224            "Should highlight parent with multiple items"
9225        );
9226
9227        // Test 6: Target is file in different directory, highlight parent
9228        let result =
9229            panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
9230        assert_eq!(
9231            result,
9232            Some(other_dir.id),
9233            "Should highlight parent of target file"
9234        );
9235
9236        // Test 7: Target is directory, always highlight
9237        let result =
9238            panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
9239        assert_eq!(
9240            result,
9241            Some(child_dir.id),
9242            "Should always highlight directories"
9243        );
9244    });
9245}
9246
9247#[gpui::test]
9248async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
9249    init_test(cx);
9250
9251    let fs = FakeFs::new(cx.executor());
9252    fs.insert_tree(
9253        "/root1",
9254        json!({
9255            "src": {
9256                "main.rs": "",
9257                "lib.rs": ""
9258            }
9259        }),
9260    )
9261    .await;
9262    fs.insert_tree(
9263        "/root2",
9264        json!({
9265            "src": {
9266                "main.rs": "",
9267                "test.rs": ""
9268            }
9269        }),
9270    )
9271    .await;
9272
9273    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9274    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9275    let workspace = window
9276        .read_with(cx, |mw, _| mw.workspace().clone())
9277        .unwrap();
9278    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9279    let panel = workspace.update_in(cx, ProjectPanel::new);
9280    cx.run_until_parked();
9281
9282    panel.update(cx, |panel, cx| {
9283        let project = panel.project.read(cx);
9284        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
9285
9286        let worktree_a = &worktrees[0];
9287        let main_rs_from_a = worktree_a
9288            .read(cx)
9289            .entry_for_path(rel_path("src/main.rs"))
9290            .unwrap();
9291
9292        let worktree_b = &worktrees[1];
9293        let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
9294        let main_rs_from_b = worktree_b
9295            .read(cx)
9296            .entry_for_path(rel_path("src/main.rs"))
9297            .unwrap();
9298
9299        // Test dragging file from worktree A onto parent of file with same relative path in worktree B
9300        let dragged_selection = DraggedSelection {
9301            active_selection: SelectedEntry {
9302                worktree_id: worktree_a.read(cx).id(),
9303                entry_id: main_rs_from_a.id,
9304            },
9305            marked_selections: Arc::new([SelectedEntry {
9306                worktree_id: worktree_a.read(cx).id(),
9307                entry_id: main_rs_from_a.id,
9308            }]),
9309        };
9310
9311        let result = panel.highlight_entry_for_selection_drag(
9312            src_dir_from_b,
9313            worktree_b.read(cx),
9314            &dragged_selection,
9315            cx,
9316        );
9317        assert_eq!(
9318            result,
9319            Some(src_dir_from_b.id),
9320            "Should highlight target directory from different worktree even with same relative path"
9321        );
9322
9323        // Test dragging file from worktree A onto file with same relative path in worktree B
9324        let result = panel.highlight_entry_for_selection_drag(
9325            main_rs_from_b,
9326            worktree_b.read(cx),
9327            &dragged_selection,
9328            cx,
9329        );
9330        assert_eq!(
9331            result,
9332            Some(src_dir_from_b.id),
9333            "Should highlight parent of target file from different worktree"
9334        );
9335    });
9336}
9337
9338#[gpui::test]
9339async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
9340    init_test(cx);
9341
9342    let fs = FakeFs::new(cx.executor());
9343    fs.insert_tree(
9344        "/root1",
9345        json!({
9346            "parent_dir": {
9347                "child_file.txt": "",
9348                "nested_dir": {
9349                    "nested_file.txt": ""
9350                }
9351            },
9352            "root_file.txt": ""
9353        }),
9354    )
9355    .await;
9356
9357    fs.insert_tree(
9358        "/root2",
9359        json!({
9360            "other_dir": {
9361                "other_file.txt": ""
9362            }
9363        }),
9364    )
9365    .await;
9366
9367    let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9368    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9369    let workspace = window
9370        .read_with(cx, |mw, _| mw.workspace().clone())
9371        .unwrap();
9372    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9373    let panel = workspace.update_in(cx, ProjectPanel::new);
9374    cx.run_until_parked();
9375
9376    panel.update(cx, |panel, cx| {
9377        let project = panel.project.read(cx);
9378        let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
9379        let worktree1 = worktrees[0].read(cx);
9380        let worktree2 = worktrees[1].read(cx);
9381        let worktree1_id = worktree1.id();
9382        let _worktree2_id = worktree2.id();
9383
9384        let root1_entry = worktree1.root_entry().unwrap();
9385        let root2_entry = worktree2.root_entry().unwrap();
9386        let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
9387        let child_file = worktree1
9388            .entry_for_path(rel_path("parent_dir/child_file.txt"))
9389            .unwrap();
9390        let nested_file = worktree1
9391            .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
9392            .unwrap();
9393        let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
9394
9395        // Test 1: Multiple entries - should always highlight background
9396        let multiple_dragged_selection = DraggedSelection {
9397            active_selection: SelectedEntry {
9398                worktree_id: worktree1_id,
9399                entry_id: child_file.id,
9400            },
9401            marked_selections: Arc::new([
9402                SelectedEntry {
9403                    worktree_id: worktree1_id,
9404                    entry_id: child_file.id,
9405                },
9406                SelectedEntry {
9407                    worktree_id: worktree1_id,
9408                    entry_id: nested_file.id,
9409                },
9410            ]),
9411        };
9412
9413        let result = panel.should_highlight_background_for_selection_drag(
9414            &multiple_dragged_selection,
9415            root1_entry.id,
9416            cx,
9417        );
9418        assert!(result, "Should highlight background for multiple entries");
9419
9420        // Test 2: Single entry with non-empty parent path - should highlight background
9421        let nested_dragged_selection = DraggedSelection {
9422            active_selection: SelectedEntry {
9423                worktree_id: worktree1_id,
9424                entry_id: nested_file.id,
9425            },
9426            marked_selections: Arc::new([SelectedEntry {
9427                worktree_id: worktree1_id,
9428                entry_id: nested_file.id,
9429            }]),
9430        };
9431
9432        let result = panel.should_highlight_background_for_selection_drag(
9433            &nested_dragged_selection,
9434            root1_entry.id,
9435            cx,
9436        );
9437        assert!(result, "Should highlight background for nested file");
9438
9439        // Test 3: Single entry at root level, same worktree - should NOT highlight background
9440        let root_file_dragged_selection = DraggedSelection {
9441            active_selection: SelectedEntry {
9442                worktree_id: worktree1_id,
9443                entry_id: root_file.id,
9444            },
9445            marked_selections: Arc::new([SelectedEntry {
9446                worktree_id: worktree1_id,
9447                entry_id: root_file.id,
9448            }]),
9449        };
9450
9451        let result = panel.should_highlight_background_for_selection_drag(
9452            &root_file_dragged_selection,
9453            root1_entry.id,
9454            cx,
9455        );
9456        assert!(
9457            !result,
9458            "Should NOT highlight background for root file in same worktree"
9459        );
9460
9461        // Test 4: Single entry at root level, different worktree - should highlight background
9462        let result = panel.should_highlight_background_for_selection_drag(
9463            &root_file_dragged_selection,
9464            root2_entry.id,
9465            cx,
9466        );
9467        assert!(
9468            result,
9469            "Should highlight background for root file from different worktree"
9470        );
9471
9472        // Test 5: Single entry in subdirectory - should highlight background
9473        let child_file_dragged_selection = DraggedSelection {
9474            active_selection: SelectedEntry {
9475                worktree_id: worktree1_id,
9476                entry_id: child_file.id,
9477            },
9478            marked_selections: Arc::new([SelectedEntry {
9479                worktree_id: worktree1_id,
9480                entry_id: child_file.id,
9481            }]),
9482        };
9483
9484        let result = panel.should_highlight_background_for_selection_drag(
9485            &child_file_dragged_selection,
9486            root1_entry.id,
9487            cx,
9488        );
9489        assert!(
9490            result,
9491            "Should highlight background for file with non-empty parent path"
9492        );
9493    });
9494}
9495
9496#[gpui::test]
9497async fn test_hide_root(cx: &mut gpui::TestAppContext) {
9498    init_test(cx);
9499
9500    let fs = FakeFs::new(cx.executor());
9501    fs.insert_tree(
9502        "/root1",
9503        json!({
9504            "dir1": {
9505                "file1.txt": "content",
9506                "file2.txt": "content",
9507            },
9508            "dir2": {
9509                "file3.txt": "content",
9510            },
9511            "file4.txt": "content",
9512        }),
9513    )
9514    .await;
9515
9516    fs.insert_tree(
9517        "/root2",
9518        json!({
9519            "dir3": {
9520                "file5.txt": "content",
9521            },
9522            "file6.txt": "content",
9523        }),
9524    )
9525    .await;
9526
9527    // Test 1: Single worktree with hide_root = false
9528    {
9529        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9530        let window =
9531            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9532        let workspace = window
9533            .read_with(cx, |mw, _| mw.workspace().clone())
9534            .unwrap();
9535        let cx = &mut VisualTestContext::from_window(window.into(), cx);
9536
9537        cx.update(|_, cx| {
9538            let settings = *ProjectPanelSettings::get_global(cx);
9539            ProjectPanelSettings::override_global(
9540                ProjectPanelSettings {
9541                    hide_root: false,
9542                    ..settings
9543                },
9544                cx,
9545            );
9546        });
9547
9548        let panel = workspace.update_in(cx, ProjectPanel::new);
9549        cx.run_until_parked();
9550
9551        #[rustfmt::skip]
9552        assert_eq!(
9553            visible_entries_as_strings(&panel, 0..10, cx),
9554            &[
9555                "v root1",
9556                "    > dir1",
9557                "    > dir2",
9558                "      file4.txt",
9559            ],
9560            "With hide_root=false and single worktree, root should be visible"
9561        );
9562    }
9563
9564    // Test 2: Single worktree with hide_root = true
9565    {
9566        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9567        let window =
9568            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9569        let workspace = window
9570            .read_with(cx, |mw, _| mw.workspace().clone())
9571            .unwrap();
9572        let cx = &mut VisualTestContext::from_window(window.into(), cx);
9573
9574        // Set hide_root to true
9575        cx.update(|_, cx| {
9576            let settings = *ProjectPanelSettings::get_global(cx);
9577            ProjectPanelSettings::override_global(
9578                ProjectPanelSettings {
9579                    hide_root: true,
9580                    ..settings
9581                },
9582                cx,
9583            );
9584        });
9585
9586        let panel = workspace.update_in(cx, ProjectPanel::new);
9587        cx.run_until_parked();
9588
9589        assert_eq!(
9590            visible_entries_as_strings(&panel, 0..10, cx),
9591            &["> dir1", "> dir2", "  file4.txt",],
9592            "With hide_root=true and single worktree, root should be hidden"
9593        );
9594
9595        // Test expanding directories still works without root
9596        toggle_expand_dir(&panel, "root1/dir1", cx);
9597        assert_eq!(
9598            visible_entries_as_strings(&panel, 0..10, cx),
9599            &[
9600                "v dir1  <== selected",
9601                "      file1.txt",
9602                "      file2.txt",
9603                "> dir2",
9604                "  file4.txt",
9605            ],
9606            "Should be able to expand directories even when root is hidden"
9607        );
9608    }
9609
9610    // Test 3: Multiple worktrees with hide_root = true
9611    {
9612        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9613        let window =
9614            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9615        let workspace = window
9616            .read_with(cx, |mw, _| mw.workspace().clone())
9617            .unwrap();
9618        let cx = &mut VisualTestContext::from_window(window.into(), cx);
9619
9620        // Set hide_root to true
9621        cx.update(|_, cx| {
9622            let settings = *ProjectPanelSettings::get_global(cx);
9623            ProjectPanelSettings::override_global(
9624                ProjectPanelSettings {
9625                    hide_root: true,
9626                    ..settings
9627                },
9628                cx,
9629            );
9630        });
9631
9632        let panel = workspace.update_in(cx, ProjectPanel::new);
9633        cx.run_until_parked();
9634
9635        assert_eq!(
9636            visible_entries_as_strings(&panel, 0..10, cx),
9637            &[
9638                "v root1",
9639                "    > dir1",
9640                "    > dir2",
9641                "      file4.txt",
9642                "v root2",
9643                "    > dir3",
9644                "      file6.txt",
9645            ],
9646            "With hide_root=true and multiple worktrees, roots should still be visible"
9647        );
9648    }
9649
9650    // Test 4: Multiple worktrees with hide_root = false
9651    {
9652        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9653        let window =
9654            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9655        let workspace = window
9656            .read_with(cx, |mw, _| mw.workspace().clone())
9657            .unwrap();
9658        let cx = &mut VisualTestContext::from_window(window.into(), cx);
9659
9660        cx.update(|_, cx| {
9661            let settings = *ProjectPanelSettings::get_global(cx);
9662            ProjectPanelSettings::override_global(
9663                ProjectPanelSettings {
9664                    hide_root: false,
9665                    ..settings
9666                },
9667                cx,
9668            );
9669        });
9670
9671        let panel = workspace.update_in(cx, ProjectPanel::new);
9672        cx.run_until_parked();
9673
9674        assert_eq!(
9675            visible_entries_as_strings(&panel, 0..10, cx),
9676            &[
9677                "v root1",
9678                "    > dir1",
9679                "    > dir2",
9680                "      file4.txt",
9681                "v root2",
9682                "    > dir3",
9683                "      file6.txt",
9684            ],
9685            "With hide_root=false and multiple worktrees, roots should be visible"
9686        );
9687    }
9688}
9689
9690#[gpui::test]
9691async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
9692    init_test_with_editor(cx);
9693
9694    let fs = FakeFs::new(cx.executor());
9695    fs.insert_tree(
9696        "/root",
9697        json!({
9698            "file1.txt": "content of file1",
9699            "file2.txt": "content of file2",
9700            "dir1": {
9701                "file3.txt": "content of file3"
9702            }
9703        }),
9704    )
9705    .await;
9706
9707    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9708    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9709    let workspace = window
9710        .read_with(cx, |mw, _| mw.workspace().clone())
9711        .unwrap();
9712    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9713    let panel = workspace.update_in(cx, ProjectPanel::new);
9714    cx.run_until_parked();
9715
9716    let file1_path = "root/file1.txt";
9717    let file2_path = "root/file2.txt";
9718    select_path_with_mark(&panel, file1_path, cx);
9719    select_path_with_mark(&panel, file2_path, cx);
9720
9721    panel.update_in(cx, |panel, window, cx| {
9722        panel.compare_marked_files(&CompareMarkedFiles, window, cx);
9723    });
9724    cx.executor().run_until_parked();
9725
9726    workspace.update_in(cx, |workspace, _, cx| {
9727        let active_items = workspace
9728            .panes()
9729            .iter()
9730            .filter_map(|pane| pane.read(cx).active_item())
9731            .collect::<Vec<_>>();
9732        assert_eq!(active_items.len(), 1);
9733        let diff_view = active_items
9734            .into_iter()
9735            .next()
9736            .unwrap()
9737            .downcast::<FileDiffView>()
9738            .expect("Open item should be an FileDiffView");
9739        assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
9740        assert_eq!(
9741            diff_view.tab_tooltip_text(cx).unwrap(),
9742            format!(
9743                "{} ↔ {}",
9744                rel_path(file1_path).display(PathStyle::local()),
9745                rel_path(file2_path).display(PathStyle::local())
9746            )
9747        );
9748    });
9749
9750    let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
9751    let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
9752    let worktree_id = panel.update(cx, |panel, cx| {
9753        panel
9754            .project
9755            .read(cx)
9756            .worktrees(cx)
9757            .next()
9758            .unwrap()
9759            .read(cx)
9760            .id()
9761    });
9762
9763    let expected_entries = [
9764        SelectedEntry {
9765            worktree_id,
9766            entry_id: file1_entry_id,
9767        },
9768        SelectedEntry {
9769            worktree_id,
9770            entry_id: file2_entry_id,
9771        },
9772    ];
9773    panel.update(cx, |panel, _cx| {
9774        assert_eq!(
9775            &panel.marked_entries, &expected_entries,
9776            "Should keep marked entries after comparison"
9777        );
9778    });
9779
9780    panel.update(cx, |panel, cx| {
9781        panel.project.update(cx, |_, cx| {
9782            cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
9783        })
9784    });
9785
9786    panel.update(cx, |panel, _cx| {
9787        assert_eq!(
9788            &panel.marked_entries, &expected_entries,
9789            "Marked entries should persist after focusing back on the project panel"
9790        );
9791    });
9792}
9793
9794#[gpui::test]
9795async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
9796    init_test_with_editor(cx);
9797
9798    let fs = FakeFs::new(cx.executor());
9799    fs.insert_tree(
9800        "/root",
9801        json!({
9802            "file1.txt": "content of file1",
9803            "file2.txt": "content of file2",
9804            "dir1": {},
9805            "dir2": {
9806                "file3.txt": "content of file3"
9807            }
9808        }),
9809    )
9810    .await;
9811
9812    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9813    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9814    let workspace = window
9815        .read_with(cx, |mw, _| mw.workspace().clone())
9816        .unwrap();
9817    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9818    let panel = workspace.update_in(cx, ProjectPanel::new);
9819    cx.run_until_parked();
9820
9821    // Test 1: When only one file is selected, there should be no compare option
9822    select_path(&panel, "root/file1.txt", cx);
9823
9824    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9825    assert_eq!(
9826        selected_files, None,
9827        "Should not have compare option when only one file is selected"
9828    );
9829
9830    // Test 2: When multiple files are selected, there should be a compare option
9831    select_path_with_mark(&panel, "root/file1.txt", cx);
9832    select_path_with_mark(&panel, "root/file2.txt", cx);
9833
9834    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9835    assert!(
9836        selected_files.is_some(),
9837        "Should have files selected for comparison"
9838    );
9839    if let Some((file1, file2)) = selected_files {
9840        assert!(
9841            file1.to_string_lossy().ends_with("file1.txt")
9842                && file2.to_string_lossy().ends_with("file2.txt"),
9843            "Should have file1.txt and file2.txt as the selected files when multi-selecting"
9844        );
9845    }
9846
9847    // Test 3: Selecting a directory shouldn't count as a comparable file
9848    select_path_with_mark(&panel, "root/dir1", cx);
9849
9850    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9851    assert!(
9852        selected_files.is_some(),
9853        "Directory selection should not affect comparable files"
9854    );
9855    if let Some((file1, file2)) = selected_files {
9856        assert!(
9857            file1.to_string_lossy().ends_with("file1.txt")
9858                && file2.to_string_lossy().ends_with("file2.txt"),
9859            "Selecting a directory should not affect the number of comparable files"
9860        );
9861    }
9862
9863    // Test 4: Selecting one more file
9864    select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
9865
9866    let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9867    assert!(
9868        selected_files.is_some(),
9869        "Directory selection should not affect comparable files"
9870    );
9871    if let Some((file1, file2)) = selected_files {
9872        assert!(
9873            file1.to_string_lossy().ends_with("file2.txt")
9874                && file2.to_string_lossy().ends_with("file3.txt"),
9875            "Selecting a directory should not affect the number of comparable files"
9876        );
9877    }
9878}
9879
9880#[gpui::test]
9881async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root(
9882    cx: &mut gpui::TestAppContext,
9883) {
9884    init_test(cx);
9885
9886    let fs = FakeFs::new(cx.executor());
9887    fs.insert_tree(
9888        "/root",
9889        json!({
9890            "file.txt": "content",
9891            "dir": {},
9892        }),
9893    )
9894    .await;
9895
9896    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9897    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9898    let workspace = window
9899        .read_with(cx, |mw, _| mw.workspace().clone())
9900        .unwrap();
9901    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9902    let panel = workspace.update_in(cx, ProjectPanel::new);
9903    cx.run_until_parked();
9904
9905    select_path(&panel, "root/file.txt", cx);
9906    let selected_reveal_path = panel
9907        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
9908        .expect("selected entry should produce a reveal path");
9909    assert!(
9910        selected_reveal_path.ends_with(Path::new("file.txt")),
9911        "Expected selected file path, got {:?}",
9912        selected_reveal_path
9913    );
9914
9915    panel.update(cx, |panel, _| {
9916        panel.selection = None;
9917        panel.marked_entries.clear();
9918    });
9919    let fallback_reveal_path = panel
9920        .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
9921        .expect("project root should be used when selection is empty");
9922    assert!(
9923        fallback_reveal_path.ends_with(Path::new("root")),
9924        "Expected worktree root path, got {:?}",
9925        fallback_reveal_path
9926    );
9927}
9928
9929#[gpui::test]
9930async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
9931    init_test(cx);
9932
9933    let fs = FakeFs::new(cx.executor());
9934    fs.insert_tree(
9935        "/root",
9936        json!({
9937            ".hidden-file.txt": "hidden file content",
9938            "visible-file.txt": "visible file content",
9939            ".hidden-parent-dir": {
9940                "nested-dir": {
9941                    "file.txt": "file content",
9942                }
9943            },
9944            "visible-dir": {
9945                "file-in-visible.txt": "file content",
9946                "nested": {
9947                    ".hidden-nested-dir": {
9948                        ".double-hidden-dir": {
9949                            "deep-file-1.txt": "deep content 1",
9950                            "deep-file-2.txt": "deep content 2"
9951                        },
9952                        "hidden-nested-file-1.txt": "hidden nested 1",
9953                        "hidden-nested-file-2.txt": "hidden nested 2"
9954                    },
9955                    "visible-nested-file.txt": "visible nested content"
9956                }
9957            }
9958        }),
9959    )
9960    .await;
9961
9962    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9963    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9964    let workspace = window
9965        .read_with(cx, |mw, _| mw.workspace().clone())
9966        .unwrap();
9967    let cx = &mut VisualTestContext::from_window(window.into(), cx);
9968
9969    cx.update(|_, cx| {
9970        let settings = *ProjectPanelSettings::get_global(cx);
9971        ProjectPanelSettings::override_global(
9972            ProjectPanelSettings {
9973                hide_hidden: false,
9974                ..settings
9975            },
9976            cx,
9977        );
9978    });
9979
9980    let panel = workspace.update_in(cx, ProjectPanel::new);
9981    cx.run_until_parked();
9982
9983    toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
9984    toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
9985    toggle_expand_dir(&panel, "root/visible-dir", cx);
9986    toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
9987    toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
9988    toggle_expand_dir(
9989        &panel,
9990        "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
9991        cx,
9992    );
9993
9994    let expanded = [
9995        "v root",
9996        "    v .hidden-parent-dir",
9997        "        v nested-dir",
9998        "              file.txt",
9999        "    v visible-dir",
10000        "        v nested",
10001        "            v .hidden-nested-dir",
10002        "                v .double-hidden-dir  <== selected",
10003        "                      deep-file-1.txt",
10004        "                      deep-file-2.txt",
10005        "                  hidden-nested-file-1.txt",
10006        "                  hidden-nested-file-2.txt",
10007        "              visible-nested-file.txt",
10008        "          file-in-visible.txt",
10009        "      .hidden-file.txt",
10010        "      visible-file.txt",
10011    ];
10012
10013    assert_eq!(
10014        visible_entries_as_strings(&panel, 0..30, cx),
10015        &expanded,
10016        "With hide_hidden=false, contents of hidden nested directory should be visible"
10017    );
10018
10019    cx.update(|_, cx| {
10020        let settings = *ProjectPanelSettings::get_global(cx);
10021        ProjectPanelSettings::override_global(
10022            ProjectPanelSettings {
10023                hide_hidden: true,
10024                ..settings
10025            },
10026            cx,
10027        );
10028    });
10029
10030    panel.update_in(cx, |panel, window, cx| {
10031        panel.update_visible_entries(None, false, false, window, cx);
10032    });
10033    cx.run_until_parked();
10034
10035    assert_eq!(
10036        visible_entries_as_strings(&panel, 0..30, cx),
10037        &[
10038            "v root",
10039            "    v visible-dir",
10040            "        v nested",
10041            "              visible-nested-file.txt",
10042            "          file-in-visible.txt",
10043            "      visible-file.txt",
10044        ],
10045        "With hide_hidden=false, contents of hidden nested directory should be visible"
10046    );
10047
10048    panel.update_in(cx, |panel, window, cx| {
10049        let settings = *ProjectPanelSettings::get_global(cx);
10050        ProjectPanelSettings::override_global(
10051            ProjectPanelSettings {
10052                hide_hidden: false,
10053                ..settings
10054            },
10055            cx,
10056        );
10057        panel.update_visible_entries(None, false, false, window, cx);
10058    });
10059    cx.run_until_parked();
10060
10061    assert_eq!(
10062        visible_entries_as_strings(&panel, 0..30, cx),
10063        &expanded,
10064        "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
10065    );
10066}
10067
10068pub(crate) fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
10069    let path = rel_path(path);
10070    panel.update_in(cx, |panel, window, cx| {
10071        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
10072            let worktree = worktree.read(cx);
10073            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
10074                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
10075                panel.update_visible_entries(
10076                    Some((worktree.id(), entry_id)),
10077                    false,
10078                    false,
10079                    window,
10080                    cx,
10081                );
10082                return;
10083            }
10084        }
10085        panic!("no worktree for path {:?}", path);
10086    });
10087    cx.run_until_parked();
10088}
10089
10090pub(crate) fn select_path_with_mark(
10091    panel: &Entity<ProjectPanel>,
10092    path: &str,
10093    cx: &mut VisualTestContext,
10094) {
10095    let path = rel_path(path);
10096    panel.update(cx, |panel, cx| {
10097        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
10098            let worktree = worktree.read(cx);
10099            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
10100                let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
10101                let entry = crate::SelectedEntry {
10102                    worktree_id: worktree.id(),
10103                    entry_id,
10104                };
10105                if !panel.marked_entries.contains(&entry) {
10106                    panel.marked_entries.push(entry);
10107                }
10108                panel.selection = Some(entry);
10109                return;
10110            }
10111        }
10112        panic!("no worktree for path {:?}", path);
10113    });
10114}
10115
10116/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
10117/// `active_ancestor_path` is the path to the folded component that should be active.
10118fn select_folded_path_with_mark(
10119    panel: &Entity<ProjectPanel>,
10120    leaf_path: &str,
10121    active_ancestor_path: &str,
10122    cx: &mut VisualTestContext,
10123) {
10124    select_path_with_mark(panel, leaf_path, cx);
10125    set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
10126}
10127
10128fn set_folded_active_ancestor(
10129    panel: &Entity<ProjectPanel>,
10130    leaf_path: &str,
10131    active_ancestor_path: &str,
10132    cx: &mut VisualTestContext,
10133) {
10134    let leaf_path = rel_path(leaf_path);
10135    let active_ancestor_path = rel_path(active_ancestor_path);
10136    panel.update(cx, |panel, cx| {
10137        let mut leaf_entry_id = None;
10138        let mut target_entry_id = None;
10139
10140        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
10141            let worktree = worktree.read(cx);
10142            if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
10143                leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
10144            }
10145            if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
10146                target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
10147            }
10148        }
10149
10150        let leaf_entry_id =
10151            leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
10152        let target_entry_id = target_entry_id
10153            .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
10154        let folded_ancestors = panel
10155            .state
10156            .ancestors
10157            .get_mut(&leaf_entry_id)
10158            .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
10159        let ancestor_ids = folded_ancestors.ancestors.clone();
10160
10161        let mut depth_for_target = None;
10162        for depth in 0..ancestor_ids.len() {
10163            let resolved_entry_id = if depth == 0 {
10164                leaf_entry_id
10165            } else {
10166                ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
10167            };
10168            if resolved_entry_id == target_entry_id {
10169                depth_for_target = Some(depth);
10170                break;
10171            }
10172        }
10173
10174        folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
10175            panic!(
10176                "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
10177            )
10178        });
10179    });
10180}
10181
10182pub(crate) fn drag_selection_to(
10183    panel: &Entity<ProjectPanel>,
10184    target_path: &str,
10185    is_file: bool,
10186    cx: &mut VisualTestContext,
10187) {
10188    let target_entry = find_project_entry(panel, target_path, cx)
10189        .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
10190
10191    panel.update_in(cx, |panel, window, cx| {
10192        let selection = panel
10193            .selection
10194            .expect("a selection is required before dragging");
10195        let drag = DraggedSelection {
10196            active_selection: SelectedEntry {
10197                worktree_id: selection.worktree_id,
10198                entry_id: panel.resolve_entry(selection.entry_id),
10199            },
10200            marked_selections: Arc::from(panel.marked_entries.clone()),
10201        };
10202        panel.drag_onto(&drag, target_entry, is_file, window, cx);
10203    });
10204    cx.executor().run_until_parked();
10205}
10206
10207/// Drags the entries at `source_paths` onto `target_path`. Paths are worktree
10208/// root names optionally followed by a path inside the worktree, e.g. "root1"
10209/// or "root1/dir/file.txt". The first source path is the active selection.
10210pub(crate) fn drag_entries_onto(
10211    panel: &Entity<ProjectPanel>,
10212    source_paths: &[&str],
10213    target_path: &str,
10214    target_is_file: bool,
10215    cx: &mut VisualTestContext,
10216) {
10217    let target_entry_id = find_project_entry(panel, target_path, cx)
10218        .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
10219    let selections: Vec<SelectedEntry> = source_paths
10220        .iter()
10221        .map(|path| {
10222            let entry_id = find_project_entry(panel, path, cx)
10223                .unwrap_or_else(|| panic!("no entry for source path {path:?}"));
10224            let worktree_id = panel
10225                .update(cx, |panel, cx| {
10226                    panel.project.read(cx).worktree_id_for_entry(entry_id, cx)
10227                })
10228                .unwrap_or_else(|| panic!("no worktree for source path {path:?}"));
10229            SelectedEntry {
10230                worktree_id,
10231                entry_id,
10232            }
10233        })
10234        .collect();
10235    let active_selection = *selections
10236        .first()
10237        .expect("at least one source path is required");
10238
10239    panel.update_in(cx, |panel, window, cx| {
10240        let drag = DraggedSelection {
10241            active_selection,
10242            marked_selections: Arc::from(selections),
10243        };
10244        panel.drag_onto(&drag, target_entry_id, target_is_file, window, cx);
10245    });
10246    cx.executor().run_until_parked();
10247}
10248
10249pub(crate) fn find_project_entry(
10250    panel: &Entity<ProjectPanel>,
10251    path: &str,
10252    cx: &mut VisualTestContext,
10253) -> Option<ProjectEntryId> {
10254    let path = rel_path(path);
10255    panel.update(cx, |panel, cx| {
10256        for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
10257            let worktree = worktree.read(cx);
10258            if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
10259                return worktree.entry_for_path(relative_path).map(|entry| entry.id);
10260            }
10261        }
10262        panic!("no worktree for path {path:?}");
10263    })
10264}
10265
10266fn visible_entries_as_strings(
10267    panel: &Entity<ProjectPanel>,
10268    range: Range<usize>,
10269    cx: &mut VisualTestContext,
10270) -> Vec<String> {
10271    let mut result = Vec::new();
10272    let mut project_entries = HashSet::default();
10273    let mut has_editor = false;
10274
10275    panel.update_in(cx, |panel, window, cx| {
10276        panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
10277            if details.is_editing {
10278                assert!(!has_editor, "duplicate editor entry");
10279                has_editor = true;
10280            } else {
10281                assert!(
10282                    project_entries.insert(project_entry),
10283                    "duplicate project entry {:?} {:?}",
10284                    project_entry,
10285                    details
10286                );
10287            }
10288
10289            let indent = "    ".repeat(details.depth);
10290            let icon = if details.kind.is_dir() {
10291                if details.is_expanded { "v " } else { "> " }
10292            } else {
10293                "  "
10294            };
10295            #[cfg(windows)]
10296            let filename = details.filename.replace("\\", "/");
10297            #[cfg(not(windows))]
10298            let filename = details.filename;
10299            let name = if details.is_editing {
10300                format!("[EDITOR: '{}']", filename)
10301            } else if details.is_processing {
10302                format!("[PROCESSING: '{}']", filename)
10303            } else {
10304                filename
10305            };
10306            let selected = if details.is_selected {
10307                "  <== selected"
10308            } else {
10309                ""
10310            };
10311            let marked = if details.is_marked {
10312                "  <== marked"
10313            } else {
10314                ""
10315            };
10316
10317            result.push(format!("{indent}{icon}{name}{selected}{marked}"));
10318        });
10319    });
10320
10321    result
10322}
10323
10324/// Test that missing sort_mode field defaults to DirectoriesFirst
10325#[gpui::test]
10326async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
10327    init_test(cx);
10328
10329    // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
10330    let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
10331    assert_eq!(
10332        default_settings.sort_mode,
10333        settings::ProjectPanelSortMode::DirectoriesFirst,
10334        "sort_mode should default to DirectoriesFirst"
10335    );
10336}
10337
10338/// Test sort modes: DirectoriesFirst (default) vs Mixed
10339#[gpui::test]
10340async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
10341    init_test(cx);
10342
10343    let fs = FakeFs::new(cx.executor());
10344    fs.insert_tree(
10345        "/root",
10346        json!({
10347            "zebra.txt": "",
10348            "Apple": {},
10349            "banana.rs": "",
10350            "Carrot": {},
10351            "aardvark.txt": "",
10352        }),
10353    )
10354    .await;
10355
10356    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10357    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10358    let workspace = window
10359        .read_with(cx, |mw, _| mw.workspace().clone())
10360        .unwrap();
10361    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10362    let panel = workspace.update_in(cx, ProjectPanel::new);
10363    cx.run_until_parked();
10364
10365    // Default sort mode should be DirectoriesFirst
10366    assert_eq!(
10367        visible_entries_as_strings(&panel, 0..50, cx),
10368        &[
10369            "v root",
10370            "    > Apple",
10371            "    > Carrot",
10372            "      aardvark.txt",
10373            "      banana.rs",
10374            "      zebra.txt",
10375        ]
10376    );
10377}
10378
10379#[gpui::test]
10380async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
10381    init_test(cx);
10382
10383    let fs = FakeFs::new(cx.executor());
10384    fs.insert_tree(
10385        "/root",
10386        json!({
10387            "Zebra.txt": "",
10388            "apple": {},
10389            "Banana.rs": "",
10390            "carrot": {},
10391            "Aardvark.txt": "",
10392        }),
10393    )
10394    .await;
10395
10396    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10397    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10398    let workspace = window
10399        .read_with(cx, |mw, _| mw.workspace().clone())
10400        .unwrap();
10401    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10402
10403    // Switch to Mixed mode
10404    cx.update(|_, cx| {
10405        cx.update_global::<SettingsStore, _>(|store, cx| {
10406            store.update_user_settings(cx, |settings| {
10407                settings.project_panel.get_or_insert_default().sort_mode =
10408                    Some(settings::ProjectPanelSortMode::Mixed);
10409            });
10410        });
10411    });
10412
10413    let panel = workspace.update_in(cx, ProjectPanel::new);
10414    cx.run_until_parked();
10415
10416    // Mixed mode: case-insensitive sorting
10417    // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
10418    assert_eq!(
10419        visible_entries_as_strings(&panel, 0..50, cx),
10420        &[
10421            "v root",
10422            "      Aardvark.txt",
10423            "    > apple",
10424            "      Banana.rs",
10425            "    > carrot",
10426            "      Zebra.txt",
10427        ]
10428    );
10429}
10430
10431#[gpui::test]
10432async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
10433    init_test(cx);
10434
10435    let fs = FakeFs::new(cx.executor());
10436    fs.insert_tree(
10437        "/root",
10438        json!({
10439            "Zebra.txt": "",
10440            "apple": {},
10441            "Banana.rs": "",
10442            "carrot": {},
10443            "Aardvark.txt": "",
10444        }),
10445    )
10446    .await;
10447
10448    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10449    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10450    let workspace = window
10451        .read_with(cx, |mw, _| mw.workspace().clone())
10452        .unwrap();
10453    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10454
10455    // Switch to FilesFirst mode
10456    cx.update(|_, cx| {
10457        cx.update_global::<SettingsStore, _>(|store, cx| {
10458            store.update_user_settings(cx, |settings| {
10459                settings.project_panel.get_or_insert_default().sort_mode =
10460                    Some(settings::ProjectPanelSortMode::FilesFirst);
10461            });
10462        });
10463    });
10464
10465    let panel = workspace.update_in(cx, ProjectPanel::new);
10466    cx.run_until_parked();
10467
10468    // FilesFirst mode: files first, then directories (both case-insensitive)
10469    assert_eq!(
10470        visible_entries_as_strings(&panel, 0..50, cx),
10471        &[
10472            "v root",
10473            "      Aardvark.txt",
10474            "      Banana.rs",
10475            "      Zebra.txt",
10476            "    > apple",
10477            "    > carrot",
10478        ]
10479    );
10480}
10481
10482#[gpui::test]
10483async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
10484    init_test(cx);
10485
10486    let fs = FakeFs::new(cx.executor());
10487    fs.insert_tree(
10488        "/root",
10489        json!({
10490            "file2.txt": "",
10491            "dir1": {},
10492            "file1.txt": "",
10493        }),
10494    )
10495    .await;
10496
10497    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10498    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10499    let workspace = window
10500        .read_with(cx, |mw, _| mw.workspace().clone())
10501        .unwrap();
10502    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10503    let panel = workspace.update_in(cx, ProjectPanel::new);
10504    cx.run_until_parked();
10505
10506    // Initially DirectoriesFirst
10507    assert_eq!(
10508        visible_entries_as_strings(&panel, 0..50, cx),
10509        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
10510    );
10511
10512    // Toggle to Mixed
10513    cx.update(|_, cx| {
10514        cx.update_global::<SettingsStore, _>(|store, cx| {
10515            store.update_user_settings(cx, |settings| {
10516                settings.project_panel.get_or_insert_default().sort_mode =
10517                    Some(settings::ProjectPanelSortMode::Mixed);
10518            });
10519        });
10520    });
10521    cx.run_until_parked();
10522
10523    assert_eq!(
10524        visible_entries_as_strings(&panel, 0..50, cx),
10525        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
10526    );
10527
10528    // Toggle back to DirectoriesFirst
10529    cx.update(|_, cx| {
10530        cx.update_global::<SettingsStore, _>(|store, cx| {
10531            store.update_user_settings(cx, |settings| {
10532                settings.project_panel.get_or_insert_default().sort_mode =
10533                    Some(settings::ProjectPanelSortMode::DirectoriesFirst);
10534            });
10535        });
10536    });
10537    cx.run_until_parked();
10538
10539    assert_eq!(
10540        visible_entries_as_strings(&panel, 0..50, cx),
10541        &["v root", "    > dir1", "      file1.txt", "      file2.txt",]
10542    );
10543}
10544
10545#[gpui::test]
10546async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
10547    cx: &mut gpui::TestAppContext,
10548) {
10549    init_test(cx);
10550
10551    // parent: accept
10552    run_create_file_in_folded_path_case(
10553        "parent",
10554        "root1/parent",
10555        "file_in_parent.txt",
10556        &[
10557            "v root1",
10558            "    v parent",
10559            "        > subdir/child",
10560            "          [EDITOR: '']  <== selected",
10561        ],
10562        &[
10563            "v root1",
10564            "    v parent",
10565            "        > subdir/child",
10566            "          file_in_parent.txt  <== selected  <== marked",
10567        ],
10568        true,
10569        cx,
10570    )
10571    .await;
10572
10573    // parent: cancel
10574    run_create_file_in_folded_path_case(
10575        "parent",
10576        "root1/parent",
10577        "file_in_parent.txt",
10578        &[
10579            "v root1",
10580            "    v parent",
10581            "        > subdir/child",
10582            "          [EDITOR: '']  <== selected",
10583        ],
10584        &["v root1", "    > parent/subdir/child  <== selected"],
10585        false,
10586        cx,
10587    )
10588    .await;
10589
10590    // subdir: accept
10591    run_create_file_in_folded_path_case(
10592        "subdir",
10593        "root1/parent/subdir",
10594        "file_in_subdir.txt",
10595        &[
10596            "v root1",
10597            "    v parent/subdir",
10598            "        > child",
10599            "          [EDITOR: '']  <== selected",
10600        ],
10601        &[
10602            "v root1",
10603            "    v parent/subdir",
10604            "        > child",
10605            "          file_in_subdir.txt  <== selected  <== marked",
10606        ],
10607        true,
10608        cx,
10609    )
10610    .await;
10611
10612    // subdir: cancel
10613    run_create_file_in_folded_path_case(
10614        "subdir",
10615        "root1/parent/subdir",
10616        "file_in_subdir.txt",
10617        &[
10618            "v root1",
10619            "    v parent/subdir",
10620            "        > child",
10621            "          [EDITOR: '']  <== selected",
10622        ],
10623        &["v root1", "    > parent/subdir/child  <== selected"],
10624        false,
10625        cx,
10626    )
10627    .await;
10628
10629    // child: accept
10630    run_create_file_in_folded_path_case(
10631        "child",
10632        "root1/parent/subdir/child",
10633        "file_in_child.txt",
10634        &[
10635            "v root1",
10636            "    v parent/subdir/child",
10637            "          [EDITOR: '']  <== selected",
10638        ],
10639        &[
10640            "v root1",
10641            "    v parent/subdir/child",
10642            "          file_in_child.txt  <== selected  <== marked",
10643        ],
10644        true,
10645        cx,
10646    )
10647    .await;
10648
10649    // child: cancel
10650    run_create_file_in_folded_path_case(
10651        "child",
10652        "root1/parent/subdir/child",
10653        "file_in_child.txt",
10654        &[
10655            "v root1",
10656            "    v parent/subdir/child",
10657            "          [EDITOR: '']  <== selected",
10658        ],
10659        &["v root1", "    v parent/subdir/child  <== selected"],
10660        false,
10661        cx,
10662    )
10663    .await;
10664}
10665
10666#[gpui::test]
10667async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
10668    cx: &mut gpui::TestAppContext,
10669) {
10670    init_test(cx);
10671
10672    let fs = FakeFs::new(cx.executor());
10673    fs.insert_tree(
10674        "/root1",
10675        json!({
10676            "parent": {
10677                "subdir": {
10678                    "child": {},
10679                }
10680            }
10681        }),
10682    )
10683    .await;
10684
10685    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
10686    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10687    let workspace = window
10688        .read_with(cx, |mw, _| mw.workspace().clone())
10689        .unwrap();
10690    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10691
10692    let panel = workspace.update_in(cx, |workspace, window, cx| {
10693        let panel = ProjectPanel::new(workspace, window, cx);
10694        workspace.add_panel(panel.clone(), window, cx);
10695        panel
10696    });
10697
10698    cx.update(|_, cx| {
10699        let settings = *ProjectPanelSettings::get_global(cx);
10700        ProjectPanelSettings::override_global(
10701            ProjectPanelSettings {
10702                auto_fold_dirs: true,
10703                ..settings
10704            },
10705            cx,
10706        );
10707    });
10708
10709    panel.update_in(cx, |panel, window, cx| {
10710        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
10711    });
10712    cx.run_until_parked();
10713
10714    select_folded_path_with_mark(
10715        &panel,
10716        "root1/parent/subdir/child",
10717        "root1/parent/subdir",
10718        cx,
10719    );
10720    panel.update(cx, |panel, _| {
10721        panel.marked_entries.clear();
10722    });
10723
10724    let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
10725        .expect("parent directory should exist for this test");
10726    let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
10727        .expect("subdir directory should exist for this test");
10728    let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
10729        .expect("child directory should exist for this test");
10730
10731    panel.update(cx, |panel, _| {
10732        let selection = panel
10733            .selection
10734            .expect("leaf directory should be selected before creating a new entry");
10735        assert_eq!(
10736            selection.entry_id, child_entry_id,
10737            "initial selection should be the folded leaf entry"
10738        );
10739        assert_eq!(
10740            panel.resolve_entry(selection.entry_id),
10741            subdir_entry_id,
10742            "active folded component should start at subdir"
10743        );
10744    });
10745
10746    panel.update_in(cx, |panel, window, cx| {
10747        panel.deploy_context_menu(
10748            gpui::point(gpui::px(1.), gpui::px(1.)),
10749            child_entry_id,
10750            window,
10751            cx,
10752        );
10753        panel.new_file(&NewFile, window, cx);
10754    });
10755    cx.run_until_parked();
10756    panel.update_in(cx, |panel, window, cx| {
10757        assert!(panel.filename_editor.read(cx).is_focused(window));
10758    });
10759    cx.run_until_parked();
10760
10761    set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
10762
10763    panel.update_in(cx, |panel, window, cx| {
10764        panel.deploy_context_menu(
10765            gpui::point(gpui::px(2.), gpui::px(2.)),
10766            subdir_entry_id,
10767            window,
10768            cx,
10769        );
10770    });
10771    cx.run_until_parked();
10772
10773    panel.update(cx, |panel, _| {
10774        assert!(
10775            panel.state.edit_state.is_none(),
10776            "opening another context menu should blur the filename editor and discard edit state"
10777        );
10778        let selection = panel
10779            .selection
10780            .expect("selection should restore to the previously focused leaf entry");
10781        assert_eq!(
10782            selection.entry_id, child_entry_id,
10783            "blur-driven cancellation should restore the previous leaf selection"
10784        );
10785        assert_eq!(
10786            panel.resolve_entry(selection.entry_id),
10787            parent_entry_id,
10788            "temporary unfolded pending state should preserve the active ancestor chosen before blur"
10789        );
10790    });
10791
10792    panel.update_in(cx, |panel, window, cx| {
10793        panel.new_file(&NewFile, window, cx);
10794    });
10795    cx.run_until_parked();
10796    assert_eq!(
10797        visible_entries_as_strings(&panel, 0..10, cx),
10798        &[
10799            "v root1",
10800            "    v parent",
10801            "        > subdir/child",
10802            "          [EDITOR: '']  <== selected",
10803        ],
10804        "new file after blur should use the preserved active ancestor"
10805    );
10806    panel.update(cx, |panel, _| {
10807        let edit_state = panel
10808            .state
10809            .edit_state
10810            .as_ref()
10811            .expect("new file should enter edit state");
10812        assert_eq!(
10813            edit_state.temporarily_unfolded,
10814            Some(parent_entry_id),
10815            "temporary unfolding should now target parent after restoring the active ancestor"
10816        );
10817    });
10818
10819    let file_name = "created_after_blur.txt";
10820    panel
10821        .update_in(cx, |panel, window, cx| {
10822            panel.filename_editor.update(cx, |editor, cx| {
10823                editor.set_text(file_name, window, cx);
10824            });
10825            panel.confirm_edit(true, window, cx).expect(
10826                "confirm_edit should start creation for the file created after blur transition",
10827            )
10828        })
10829        .await
10830        .expect("creating file after blur transition should succeed");
10831    cx.run_until_parked();
10832
10833    assert!(
10834        fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
10835            .await,
10836        "file should be created under parent after active ancestor is restored to parent"
10837    );
10838    assert!(
10839        !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
10840            .await,
10841        "file should not be created under subdir when parent is the active ancestor"
10842    );
10843}
10844
10845async fn run_create_file_in_folded_path_case(
10846    case_name: &str,
10847    active_ancestor_path: &str,
10848    created_file_name: &str,
10849    expected_temporary_state: &[&str],
10850    expected_final_state: &[&str],
10851    accept_creation: bool,
10852    cx: &mut gpui::TestAppContext,
10853) {
10854    let expected_collapsed_state = &["v root1", "    > parent/subdir/child  <== selected"];
10855
10856    let fs = FakeFs::new(cx.executor());
10857    fs.insert_tree(
10858        "/root1",
10859        json!({
10860            "parent": {
10861                "subdir": {
10862                    "child": {},
10863                }
10864            }
10865        }),
10866    )
10867    .await;
10868
10869    let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
10870    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10871    let workspace = window
10872        .read_with(cx, |mw, _| mw.workspace().clone())
10873        .unwrap();
10874    let cx = &mut VisualTestContext::from_window(window.into(), cx);
10875
10876    let panel = workspace.update_in(cx, |workspace, window, cx| {
10877        let panel = ProjectPanel::new(workspace, window, cx);
10878        workspace.add_panel(panel.clone(), window, cx);
10879        panel
10880    });
10881
10882    cx.update(|_, cx| {
10883        let settings = *ProjectPanelSettings::get_global(cx);
10884        ProjectPanelSettings::override_global(
10885            ProjectPanelSettings {
10886                auto_fold_dirs: true,
10887                ..settings
10888            },
10889            cx,
10890        );
10891    });
10892
10893    panel.update_in(cx, |panel, window, cx| {
10894        panel.collapse_all_entries(&CollapseAllEntries, window, cx);
10895    });
10896    cx.run_until_parked();
10897
10898    select_folded_path_with_mark(
10899        &panel,
10900        "root1/parent/subdir/child",
10901        active_ancestor_path,
10902        cx,
10903    );
10904    panel.update(cx, |panel, _| {
10905        panel.marked_entries.clear();
10906    });
10907
10908    assert_eq!(
10909        visible_entries_as_strings(&panel, 0..10, cx),
10910        expected_collapsed_state,
10911        "case '{}' should start from a folded state",
10912        case_name
10913    );
10914
10915    panel.update_in(cx, |panel, window, cx| {
10916        panel.new_file(&NewFile, window, cx);
10917    });
10918    cx.run_until_parked();
10919    panel.update_in(cx, |panel, window, cx| {
10920        assert!(panel.filename_editor.read(cx).is_focused(window));
10921    });
10922    cx.run_until_parked();
10923    assert_eq!(
10924        visible_entries_as_strings(&panel, 0..10, cx),
10925        expected_temporary_state,
10926        "case '{}' ({}) should temporarily unfold the active ancestor while editing",
10927        case_name,
10928        if accept_creation { "accept" } else { "cancel" }
10929    );
10930
10931    let relative_directory = active_ancestor_path
10932        .strip_prefix("root1/")
10933        .expect("active_ancestor_path should start with root1/");
10934    let created_file_path = PathBuf::from("/root1")
10935        .join(relative_directory)
10936        .join(created_file_name);
10937
10938    if accept_creation {
10939        panel
10940            .update_in(cx, |panel, window, cx| {
10941                panel.filename_editor.update(cx, |editor, cx| {
10942                    editor.set_text(created_file_name, window, cx);
10943                });
10944                panel.confirm_edit(true, window, cx).unwrap()
10945            })
10946            .await
10947            .unwrap();
10948        cx.run_until_parked();
10949
10950        assert_eq!(
10951            visible_entries_as_strings(&panel, 0..10, cx),
10952            expected_final_state,
10953            "case '{}' should keep the newly created file selected and marked after accept",
10954            case_name
10955        );
10956        assert!(
10957            fs.is_file(created_file_path.as_path()).await,
10958            "case '{}' should create file '{}'",
10959            case_name,
10960            created_file_path.display()
10961        );
10962    } else {
10963        panel.update_in(cx, |panel, window, cx| {
10964            panel.cancel(&Cancel, window, cx);
10965        });
10966        cx.run_until_parked();
10967
10968        assert_eq!(
10969            visible_entries_as_strings(&panel, 0..10, cx),
10970            expected_final_state,
10971            "case '{}' should keep the expected panel state after cancel",
10972            case_name
10973        );
10974        assert!(
10975            !fs.is_file(created_file_path.as_path()).await,
10976            "case '{}' should not create a file after cancel",
10977            case_name
10978        );
10979    }
10980}
10981
10982#[gpui::test]
10983async fn test_focus_follows_mouse_into_blank_area(cx: &mut gpui::TestAppContext) {
10984    init_test_with_editor(cx);
10985    cx.update(|cx| {
10986        cx.update_global::<SettingsStore, _>(|store, cx| {
10987            store.update_user_settings(cx, |settings| {
10988                settings.workspace.focus_follows_mouse = Some(settings::FocusFollowsMouse {
10989                    enabled: Some(true),
10990                    debounce_ms: Some(100),
10991                });
10992            });
10993        });
10994    });
10995
10996    let fs = FakeFs::new(cx.executor());
10997    fs.insert_tree(path!("/root"), json!({ "a.txt": "" })).await;
10998
10999    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
11000    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11001    let workspace = window
11002        .read_with(cx, |mw, _| mw.workspace().clone())
11003        .unwrap();
11004    let cx = &mut VisualTestContext::from_window(window.into(), cx);
11005    let panel = workspace.update_in(cx, |workspace, window, cx| {
11006        let panel = ProjectPanel::new(workspace, window, cx);
11007        workspace.add_panel(panel.clone(), window, cx);
11008        workspace.open_panel::<ProjectPanel>(window, cx);
11009        panel
11010    });
11011    cx.run_until_parked();
11012
11013    workspace
11014        .update_in(cx, |workspace, window, cx| {
11015            let worktree_id = workspace.worktrees(cx).next().unwrap().read(cx).id();
11016            let project_path = ProjectPath {
11017                worktree_id,
11018                path: rel_path("a.txt").into(),
11019            };
11020            workspace.open_path(project_path, None, true, window, cx)
11021        })
11022        .await
11023        .unwrap();
11024    cx.run_until_parked();
11025
11026    panel.update_in(cx, |panel, window, cx| {
11027        assert!(
11028            !panel.focus_handle(cx).is_focused(window),
11029            "Editor should be focused after opening a file"
11030        );
11031    });
11032
11033    // Hover over the blank space below the last entry in the project panel,
11034    // which lives in the right dock by default.
11035    cx.simulate_mouse_move(point(px(1800.), px(600.)), None, Modifiers::none());
11036    cx.executor().advance_clock(Duration::from_millis(200));
11037    cx.run_until_parked();
11038
11039    panel.update_in(cx, |panel, window, cx| {
11040        assert!(
11041            panel.focus_handle(cx).is_focused(window),
11042            "Project panel should be focused after hovering the blank area below the entries"
11043        );
11044    });
11045}
11046
11047pub(crate) fn init_test(cx: &mut TestAppContext) {
11048    cx.update(|cx| {
11049        let settings_store = SettingsStore::test(cx);
11050        cx.set_global(settings_store);
11051        theme_settings::init(theme::LoadThemes::JustBase, cx);
11052        crate::init(cx);
11053
11054        cx.update_global::<SettingsStore, _>(|store, cx| {
11055            store.update_user_settings(cx, |settings| {
11056                settings
11057                    .project_panel
11058                    .get_or_insert_default()
11059                    .auto_fold_dirs = Some(false);
11060                settings.project.worktree.file_scan_exclusions = Some(Vec::new());
11061            });
11062        });
11063    });
11064}
11065
11066fn init_test_with_editor(cx: &mut TestAppContext) {
11067    cx.update(|cx| {
11068        let app_state = AppState::test(cx);
11069        theme_settings::init(theme::LoadThemes::JustBase, cx);
11070        editor::init(cx);
11071        crate::init(cx);
11072        workspace::init(app_state, cx);
11073
11074        cx.update_global::<SettingsStore, _>(|store, cx| {
11075            store.update_user_settings(cx, |settings| {
11076                settings
11077                    .project_panel
11078                    .get_or_insert_default()
11079                    .auto_fold_dirs = Some(false);
11080                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
11081            });
11082        });
11083    });
11084}
11085
11086fn init_test_with_git_ui(cx: &mut TestAppContext) {
11087    cx.update(|cx| {
11088        let app_state = AppState::test(cx);
11089        theme_settings::init(theme::LoadThemes::JustBase, cx);
11090        editor::init(cx);
11091        git_ui::init(cx);
11092        crate::init(cx);
11093        workspace::init(app_state, cx);
11094
11095        cx.update_global::<SettingsStore, _>(|store, cx| {
11096            store.update_user_settings(cx, |settings| {
11097                settings
11098                    .project_panel
11099                    .get_or_insert_default()
11100                    .auto_fold_dirs = Some(false);
11101                settings.project.worktree.file_scan_exclusions = Some(Vec::new())
11102            });
11103        });
11104    });
11105}
11106
11107fn set_auto_open_settings(
11108    cx: &mut TestAppContext,
11109    auto_open_settings: ProjectPanelAutoOpenSettings,
11110) {
11111    cx.update(|cx| {
11112        cx.update_global::<SettingsStore, _>(|store, cx| {
11113            store.update_user_settings(cx, |settings| {
11114                settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
11115            });
11116        })
11117    });
11118}
11119
11120fn ensure_single_file_is_opened(
11121    workspace: &Entity<Workspace>,
11122    expected_path: &str,
11123    cx: &mut VisualTestContext,
11124) {
11125    workspace.update_in(cx, |workspace, _, cx| {
11126        let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
11127        assert_eq!(worktrees.len(), 1);
11128        let worktree_id = worktrees[0].read(cx).id();
11129
11130        let open_project_paths = workspace
11131            .panes()
11132            .iter()
11133            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
11134            .collect::<Vec<_>>();
11135        assert_eq!(
11136            open_project_paths,
11137            vec![ProjectPath {
11138                worktree_id,
11139                path: Arc::from(rel_path(expected_path))
11140            }],
11141            "Should have opened file, selected in project panel"
11142        );
11143    });
11144}
11145
11146fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
11147    assert!(
11148        !cx.has_pending_prompt(),
11149        "Should have no prompts before the deletion"
11150    );
11151    panel.update_in(cx, |panel, window, cx| {
11152        panel.delete(&Delete { skip_prompt: false }, window, cx)
11153    });
11154    assert!(
11155        cx.has_pending_prompt(),
11156        "Should have a prompt after the deletion"
11157    );
11158    cx.simulate_prompt_answer("Delete");
11159    assert!(
11160        !cx.has_pending_prompt(),
11161        "Should have no prompts after prompt was replied to"
11162    );
11163    cx.executor().run_until_parked();
11164}
11165
11166fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
11167    assert!(
11168        !cx.has_pending_prompt(),
11169        "Should have no prompts before the deletion"
11170    );
11171    panel.update_in(cx, |panel, window, cx| {
11172        panel.delete(&Delete { skip_prompt: true }, window, cx)
11173    });
11174    assert!(!cx.has_pending_prompt(), "Should have received no prompts");
11175    cx.executor().run_until_parked();
11176}
11177
11178fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
11179    assert!(
11180        !cx.has_pending_prompt(),
11181        "Should have no prompts after deletion operation closes the file"
11182    );
11183    workspace.update_in(cx, |workspace, _window, cx| {
11184        let open_project_paths = workspace
11185            .panes()
11186            .iter()
11187            .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
11188            .collect::<Vec<_>>();
11189        assert!(
11190            open_project_paths.is_empty(),
11191            "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
11192        );
11193    });
11194}
11195
11196pub(crate) struct TestProjectItemView {
11197    focus_handle: FocusHandle,
11198    path: ProjectPath,
11199}
11200
11201pub(crate) struct TestProjectItem {
11202    path: ProjectPath,
11203}
11204
11205impl project::ProjectItem for TestProjectItem {
11206    fn try_open(
11207        _project: &Entity<Project>,
11208        path: &ProjectPath,
11209        cx: &mut App,
11210    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
11211        let path = path.clone();
11212        Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
11213    }
11214
11215    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
11216        None
11217    }
11218
11219    fn project_path(&self, _: &App) -> Option<ProjectPath> {
11220        Some(self.path.clone())
11221    }
11222
11223    fn is_dirty(&self) -> bool {
11224        false
11225    }
11226}
11227
11228impl ProjectItem for TestProjectItemView {
11229    type Item = TestProjectItem;
11230
11231    fn for_project_item(
11232        _: Entity<Project>,
11233        _: Option<&Pane>,
11234        project_item: Entity<Self::Item>,
11235        _: &mut Window,
11236        cx: &mut Context<Self>,
11237    ) -> Self
11238    where
11239        Self: Sized,
11240    {
11241        Self {
11242            path: project_item.update(cx, |project_item, _| project_item.path.clone()),
11243            focus_handle: cx.focus_handle(),
11244        }
11245    }
11246}
11247
11248impl Item for TestProjectItemView {
11249    type Event = ();
11250
11251    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
11252        "Test".into()
11253    }
11254}
11255
11256impl EventEmitter<()> for TestProjectItemView {}
11257
11258impl Focusable for TestProjectItemView {
11259    fn focus_handle(&self, _: &App) -> FocusHandle {
11260        self.focus_handle.clone()
11261    }
11262}
11263
11264impl Render for TestProjectItemView {
11265    fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
11266        Empty
11267    }
11268}
11269
11270#[gpui::test]
11271async fn test_delete_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) {
11272    init_test(cx);
11273
11274    let fs = FakeFs::new(cx.executor());
11275    fs.insert_tree(
11276        "/root",
11277        json!({
11278            "__somefile__": "",
11279        }),
11280    )
11281    .await;
11282
11283    let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
11284    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11285    let workspace = window
11286        .read_with(cx, |mw, _| mw.workspace().clone())
11287        .unwrap();
11288    let cx = &mut VisualTestContext::from_window(window.into(), cx);
11289    let panel = workspace.update_in(cx, ProjectPanel::new);
11290    cx.run_until_parked();
11291
11292    select_path(&panel, "root/__somefile__", cx);
11293    panel.update_in(cx, |panel, window, cx| {
11294        panel.delete(&Delete { skip_prompt: false }, window, cx)
11295    });
11296    let (message, _detail) = cx
11297        .pending_prompt()
11298        .expect("delete should show a confirmation prompt");
11299
11300    assert_eq!(
11301        message,
11302        "Are you sure you want to permanently delete `__somefile__`?"
11303    );
11304}
11305
11306#[gpui::test]
11307async fn test_restore_file_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) {
11308    init_test(cx);
11309
11310    let fs = FakeFs::new(cx.executor());
11311    fs.insert_tree(
11312        path!("/root"),
11313        json!({
11314            ".git": {},
11315            "__init__.py": "modified contents",
11316        }),
11317    )
11318    .await;
11319    fs.set_head_and_index_for_repo(
11320        path!("/root/.git").as_ref(),
11321        &[("__init__.py", "original contents".into())],
11322    );
11323
11324    let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
11325    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11326    let workspace = window
11327        .read_with(cx, |mw, _| mw.workspace().clone())
11328        .unwrap();
11329    let cx = &mut VisualTestContext::from_window(window.into(), cx);
11330    let panel = workspace.update_in(cx, ProjectPanel::new);
11331    cx.run_until_parked();
11332
11333    select_path(&panel, "root/__init__.py", cx);
11334    panel.update_in(cx, |panel, window, cx| {
11335        panel.restore_file(&git::RestoreFile { skip_prompt: false }, window, cx)
11336    });
11337    let (message, _detail) = cx
11338        .pending_prompt()
11339        .expect("restore should show a confirmation prompt");
11340
11341    assert_eq!(message, "Discard changes to `__init__.py`?");
11342}
11343
Served at tenant.openagents/omega Member data and write actions are omitted.