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