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