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 cx.executor().run_until_parked();
1841 panel.update_in(cx, |panel, window, cx| {
1842 assert!(
1843 panel.edit_state.is_some(),
1844 "Edit state should not be None after conflicting new directory name"
1845 );
1846 panel.cancel(&menu::Cancel, window, cx);
1847 });
1848 assert_eq!(
1849 visible_entries_as_strings(&panel, 0..10, cx),
1850 &[
1851 //
1852 "v src <== selected",
1853 " > test"
1854 ],
1855 "File list should be unchanged after failed folder create confirmation"
1856 );
1857
1858 select_path(&panel, "src/test/", cx);
1859 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1860 cx.executor().run_until_parked();
1861 assert_eq!(
1862 visible_entries_as_strings(&panel, 0..10, cx),
1863 &[
1864 //
1865 "v src",
1866 " > test <== selected"
1867 ]
1868 );
1869 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1870 panel.update_in(cx, |panel, window, cx| {
1871 assert!(panel.filename_editor.read(cx).is_focused(window));
1872 });
1873 assert_eq!(
1874 visible_entries_as_strings(&panel, 0..10, cx),
1875 &[
1876 "v src",
1877 " v test",
1878 " [EDITOR: ''] <== selected",
1879 " first.rs",
1880 " second.rs",
1881 " third.rs"
1882 ]
1883 );
1884 panel.update_in(cx, |panel, window, cx| {
1885 panel
1886 .filename_editor
1887 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
1888 assert!(
1889 panel.confirm_edit(window, cx).is_none(),
1890 "Should not allow to confirm on conflicting new file name"
1891 );
1892 });
1893 cx.executor().run_until_parked();
1894 panel.update_in(cx, |panel, window, cx| {
1895 assert!(
1896 panel.edit_state.is_some(),
1897 "Edit state should not be None after conflicting new file name"
1898 );
1899 panel.cancel(&menu::Cancel, window, cx);
1900 });
1901 assert_eq!(
1902 visible_entries_as_strings(&panel, 0..10, cx),
1903 &[
1904 "v src",
1905 " v test <== selected",
1906 " first.rs",
1907 " second.rs",
1908 " third.rs"
1909 ],
1910 "File list should be unchanged after failed file create confirmation"
1911 );
1912
1913 select_path(&panel, "src/test/first.rs", cx);
1914 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1915 cx.executor().run_until_parked();
1916 assert_eq!(
1917 visible_entries_as_strings(&panel, 0..10, cx),
1918 &[
1919 "v src",
1920 " v test",
1921 " first.rs <== selected",
1922 " second.rs",
1923 " third.rs"
1924 ],
1925 );
1926 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
1927 panel.update_in(cx, |panel, window, cx| {
1928 assert!(panel.filename_editor.read(cx).is_focused(window));
1929 });
1930 assert_eq!(
1931 visible_entries_as_strings(&panel, 0..10, cx),
1932 &[
1933 "v src",
1934 " v test",
1935 " [EDITOR: 'first.rs'] <== selected",
1936 " second.rs",
1937 " third.rs"
1938 ]
1939 );
1940 panel.update_in(cx, |panel, window, cx| {
1941 panel
1942 .filename_editor
1943 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
1944 assert!(
1945 panel.confirm_edit(window, cx).is_none(),
1946 "Should not allow to confirm on conflicting file rename"
1947 )
1948 });
1949 cx.executor().run_until_parked();
1950 panel.update_in(cx, |panel, window, cx| {
1951 assert!(
1952 panel.edit_state.is_some(),
1953 "Edit state should not be None after conflicting file rename"
1954 );
1955 panel.cancel(&menu::Cancel, window, cx);
1956 });
1957 assert_eq!(
1958 visible_entries_as_strings(&panel, 0..10, cx),
1959 &[
1960 "v src",
1961 " v test",
1962 " first.rs <== selected",
1963 " second.rs",
1964 " third.rs"
1965 ],
1966 "File list should be unchanged after failed rename confirmation"
1967 );
1968}
1969
1970#[gpui::test]
1971async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
1972 init_test_with_editor(cx);
1973
1974 let fs = FakeFs::new(cx.executor().clone());
1975 fs.insert_tree(
1976 path!("/root"),
1977 json!({
1978 "tree1": {
1979 ".git": {},
1980 "dir1": {
1981 "modified1.txt": "1",
1982 "unmodified1.txt": "1",
1983 "modified2.txt": "1",
1984 },
1985 "dir2": {
1986 "modified3.txt": "1",
1987 "unmodified2.txt": "1",
1988 },
1989 "modified4.txt": "1",
1990 "unmodified3.txt": "1",
1991 },
1992 "tree2": {
1993 ".git": {},
1994 "dir3": {
1995 "modified5.txt": "1",
1996 "unmodified4.txt": "1",
1997 },
1998 "modified6.txt": "1",
1999 "unmodified5.txt": "1",
2000 }
2001 }),
2002 )
2003 .await;
2004
2005 // Mark files as git modified
2006 fs.set_git_content_for_repo(
2007 path!("/root/tree1/.git").as_ref(),
2008 &[
2009 ("dir1/modified1.txt".into(), "modified".into(), None),
2010 ("dir1/modified2.txt".into(), "modified".into(), None),
2011 ("modified4.txt".into(), "modified".into(), None),
2012 ("dir2/modified3.txt".into(), "modified".into(), None),
2013 ],
2014 );
2015 fs.set_git_content_for_repo(
2016 path!("/root/tree2/.git").as_ref(),
2017 &[
2018 ("dir3/modified5.txt".into(), "modified".into(), None),
2019 ("modified6.txt".into(), "modified".into(), None),
2020 ],
2021 );
2022
2023 let project = Project::test(
2024 fs.clone(),
2025 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2026 cx,
2027 )
2028 .await;
2029 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2030 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2031 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2032
2033 // Check initial state
2034 assert_eq!(
2035 visible_entries_as_strings(&panel, 0..15, cx),
2036 &[
2037 "v tree1",
2038 " > .git",
2039 " > dir1",
2040 " > dir2",
2041 " modified4.txt",
2042 " unmodified3.txt",
2043 "v tree2",
2044 " > .git",
2045 " > dir3",
2046 " modified6.txt",
2047 " unmodified5.txt"
2048 ],
2049 );
2050
2051 // Test selecting next modified entry
2052 panel.update_in(cx, |panel, window, cx| {
2053 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2054 });
2055
2056 assert_eq!(
2057 visible_entries_as_strings(&panel, 0..6, cx),
2058 &[
2059 "v tree1",
2060 " > .git",
2061 " v dir1",
2062 " modified1.txt <== selected",
2063 " modified2.txt",
2064 " unmodified1.txt",
2065 ],
2066 );
2067
2068 panel.update_in(cx, |panel, window, cx| {
2069 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2070 });
2071
2072 assert_eq!(
2073 visible_entries_as_strings(&panel, 0..6, cx),
2074 &[
2075 "v tree1",
2076 " > .git",
2077 " v dir1",
2078 " modified1.txt",
2079 " modified2.txt <== selected",
2080 " unmodified1.txt",
2081 ],
2082 );
2083
2084 panel.update_in(cx, |panel, window, cx| {
2085 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2086 });
2087
2088 assert_eq!(
2089 visible_entries_as_strings(&panel, 6..9, cx),
2090 &[
2091 " v dir2",
2092 " modified3.txt <== selected",
2093 " unmodified2.txt",
2094 ],
2095 );
2096
2097 panel.update_in(cx, |panel, window, cx| {
2098 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2099 });
2100
2101 assert_eq!(
2102 visible_entries_as_strings(&panel, 9..11, cx),
2103 &[" modified4.txt <== selected", " unmodified3.txt",],
2104 );
2105
2106 panel.update_in(cx, |panel, window, cx| {
2107 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2108 });
2109
2110 assert_eq!(
2111 visible_entries_as_strings(&panel, 13..16, cx),
2112 &[
2113 " v dir3",
2114 " modified5.txt <== selected",
2115 " unmodified4.txt",
2116 ],
2117 );
2118
2119 panel.update_in(cx, |panel, window, cx| {
2120 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2121 });
2122
2123 assert_eq!(
2124 visible_entries_as_strings(&panel, 16..18, cx),
2125 &[" modified6.txt <== selected", " unmodified5.txt",],
2126 );
2127
2128 // Wraps around to first modified file
2129 panel.update_in(cx, |panel, window, cx| {
2130 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2131 });
2132
2133 assert_eq!(
2134 visible_entries_as_strings(&panel, 0..18, cx),
2135 &[
2136 "v tree1",
2137 " > .git",
2138 " v dir1",
2139 " modified1.txt <== selected",
2140 " modified2.txt",
2141 " unmodified1.txt",
2142 " v dir2",
2143 " modified3.txt",
2144 " unmodified2.txt",
2145 " modified4.txt",
2146 " unmodified3.txt",
2147 "v tree2",
2148 " > .git",
2149 " v dir3",
2150 " modified5.txt",
2151 " unmodified4.txt",
2152 " modified6.txt",
2153 " unmodified5.txt",
2154 ],
2155 );
2156
2157 // Wraps around again to last modified file
2158 panel.update_in(cx, |panel, window, cx| {
2159 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2160 });
2161
2162 assert_eq!(
2163 visible_entries_as_strings(&panel, 16..18, cx),
2164 &[" modified6.txt <== selected", " unmodified5.txt",],
2165 );
2166
2167 panel.update_in(cx, |panel, window, cx| {
2168 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2169 });
2170
2171 assert_eq!(
2172 visible_entries_as_strings(&panel, 13..16, cx),
2173 &[
2174 " v dir3",
2175 " modified5.txt <== selected",
2176 " unmodified4.txt",
2177 ],
2178 );
2179
2180 panel.update_in(cx, |panel, window, cx| {
2181 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2182 });
2183
2184 assert_eq!(
2185 visible_entries_as_strings(&panel, 9..11, cx),
2186 &[" modified4.txt <== selected", " unmodified3.txt",],
2187 );
2188
2189 panel.update_in(cx, |panel, window, cx| {
2190 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2191 });
2192
2193 assert_eq!(
2194 visible_entries_as_strings(&panel, 6..9, cx),
2195 &[
2196 " v dir2",
2197 " modified3.txt <== selected",
2198 " unmodified2.txt",
2199 ],
2200 );
2201
2202 panel.update_in(cx, |panel, window, cx| {
2203 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2204 });
2205
2206 assert_eq!(
2207 visible_entries_as_strings(&panel, 0..6, cx),
2208 &[
2209 "v tree1",
2210 " > .git",
2211 " v dir1",
2212 " modified1.txt",
2213 " modified2.txt <== selected",
2214 " unmodified1.txt",
2215 ],
2216 );
2217
2218 panel.update_in(cx, |panel, window, cx| {
2219 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2220 });
2221
2222 assert_eq!(
2223 visible_entries_as_strings(&panel, 0..6, cx),
2224 &[
2225 "v tree1",
2226 " > .git",
2227 " v dir1",
2228 " modified1.txt <== selected",
2229 " modified2.txt",
2230 " unmodified1.txt",
2231 ],
2232 );
2233}
2234
2235#[gpui::test]
2236async fn test_select_directory(cx: &mut gpui::TestAppContext) {
2237 init_test_with_editor(cx);
2238
2239 let fs = FakeFs::new(cx.executor().clone());
2240 fs.insert_tree(
2241 "/project_root",
2242 json!({
2243 "dir_1": {
2244 "nested_dir": {
2245 "file_a.py": "# File contents",
2246 }
2247 },
2248 "file_1.py": "# File contents",
2249 "dir_2": {
2250
2251 },
2252 "dir_3": {
2253
2254 },
2255 "file_2.py": "# File contents",
2256 "dir_4": {
2257
2258 },
2259 }),
2260 )
2261 .await;
2262
2263 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2264 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2265 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2266 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2267
2268 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2269 cx.executor().run_until_parked();
2270 select_path(&panel, "project_root/dir_1", cx);
2271 cx.executor().run_until_parked();
2272 assert_eq!(
2273 visible_entries_as_strings(&panel, 0..10, cx),
2274 &[
2275 "v project_root",
2276 " > dir_1 <== selected",
2277 " > dir_2",
2278 " > dir_3",
2279 " > dir_4",
2280 " file_1.py",
2281 " file_2.py",
2282 ]
2283 );
2284 panel.update_in(cx, |panel, window, cx| {
2285 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2286 });
2287
2288 assert_eq!(
2289 visible_entries_as_strings(&panel, 0..10, cx),
2290 &[
2291 "v project_root <== selected",
2292 " > dir_1",
2293 " > dir_2",
2294 " > dir_3",
2295 " > dir_4",
2296 " file_1.py",
2297 " file_2.py",
2298 ]
2299 );
2300
2301 panel.update_in(cx, |panel, window, cx| {
2302 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2303 });
2304
2305 assert_eq!(
2306 visible_entries_as_strings(&panel, 0..10, cx),
2307 &[
2308 "v project_root",
2309 " > dir_1",
2310 " > dir_2",
2311 " > dir_3",
2312 " > dir_4 <== selected",
2313 " file_1.py",
2314 " file_2.py",
2315 ]
2316 );
2317
2318 panel.update_in(cx, |panel, window, cx| {
2319 panel.select_next_directory(&SelectNextDirectory, window, cx)
2320 });
2321
2322 assert_eq!(
2323 visible_entries_as_strings(&panel, 0..10, cx),
2324 &[
2325 "v project_root <== selected",
2326 " > dir_1",
2327 " > dir_2",
2328 " > dir_3",
2329 " > dir_4",
2330 " file_1.py",
2331 " file_2.py",
2332 ]
2333 );
2334}
2335#[gpui::test]
2336async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
2337 init_test_with_editor(cx);
2338
2339 let fs = FakeFs::new(cx.executor().clone());
2340 fs.insert_tree(
2341 "/project_root",
2342 json!({
2343 "dir_1": {
2344 "nested_dir": {
2345 "file_a.py": "# File contents",
2346 }
2347 },
2348 "file_1.py": "# File contents",
2349 "file_2.py": "# File contents",
2350 "zdir_2": {
2351 "nested_dir2": {
2352 "file_b.py": "# File contents",
2353 }
2354 },
2355 }),
2356 )
2357 .await;
2358
2359 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2360 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2361 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2362 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2363
2364 assert_eq!(
2365 visible_entries_as_strings(&panel, 0..10, cx),
2366 &[
2367 "v project_root",
2368 " > dir_1",
2369 " > zdir_2",
2370 " file_1.py",
2371 " file_2.py",
2372 ]
2373 );
2374 panel.update_in(cx, |panel, window, cx| {
2375 panel.select_first(&SelectFirst, window, cx)
2376 });
2377
2378 assert_eq!(
2379 visible_entries_as_strings(&panel, 0..10, cx),
2380 &[
2381 "v project_root <== selected",
2382 " > dir_1",
2383 " > zdir_2",
2384 " file_1.py",
2385 " file_2.py",
2386 ]
2387 );
2388
2389 panel.update_in(cx, |panel, window, cx| {
2390 panel.select_last(&SelectLast, window, cx)
2391 });
2392
2393 assert_eq!(
2394 visible_entries_as_strings(&panel, 0..10, cx),
2395 &[
2396 "v project_root",
2397 " > dir_1",
2398 " > zdir_2",
2399 " file_1.py",
2400 " file_2.py <== selected",
2401 ]
2402 );
2403}
2404
2405#[gpui::test]
2406async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2407 init_test_with_editor(cx);
2408
2409 let fs = FakeFs::new(cx.executor().clone());
2410 fs.insert_tree(
2411 "/project_root",
2412 json!({
2413 "dir_1": {
2414 "nested_dir": {
2415 "file_a.py": "# File contents",
2416 }
2417 },
2418 "file_1.py": "# File contents",
2419 }),
2420 )
2421 .await;
2422
2423 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2424 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2425 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2426 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2427
2428 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2429 cx.executor().run_until_parked();
2430 select_path(&panel, "project_root/dir_1", cx);
2431 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2432 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2433 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2434 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2435 cx.executor().run_until_parked();
2436 assert_eq!(
2437 visible_entries_as_strings(&panel, 0..10, cx),
2438 &[
2439 "v project_root",
2440 " v dir_1",
2441 " > nested_dir <== selected",
2442 " file_1.py",
2443 ]
2444 );
2445}
2446
2447#[gpui::test]
2448async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2449 init_test_with_editor(cx);
2450
2451 let fs = FakeFs::new(cx.executor().clone());
2452 fs.insert_tree(
2453 "/project_root",
2454 json!({
2455 "dir_1": {
2456 "nested_dir": {
2457 "file_a.py": "# File contents",
2458 "file_b.py": "# File contents",
2459 "file_c.py": "# File contents",
2460 },
2461 "file_1.py": "# File contents",
2462 "file_2.py": "# File contents",
2463 "file_3.py": "# File contents",
2464 },
2465 "dir_2": {
2466 "file_1.py": "# File contents",
2467 "file_2.py": "# File contents",
2468 "file_3.py": "# File contents",
2469 }
2470 }),
2471 )
2472 .await;
2473
2474 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2475 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2476 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2477 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2478
2479 panel.update_in(cx, |panel, window, cx| {
2480 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2481 });
2482 cx.executor().run_until_parked();
2483 assert_eq!(
2484 visible_entries_as_strings(&panel, 0..10, cx),
2485 &["v project_root", " > dir_1", " > dir_2",]
2486 );
2487
2488 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2489 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2490 cx.executor().run_until_parked();
2491 assert_eq!(
2492 visible_entries_as_strings(&panel, 0..10, cx),
2493 &[
2494 "v project_root",
2495 " v dir_1 <== selected",
2496 " > nested_dir",
2497 " file_1.py",
2498 " file_2.py",
2499 " file_3.py",
2500 " > dir_2",
2501 ]
2502 );
2503}
2504
2505#[gpui::test]
2506async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2507 init_test(cx);
2508
2509 let fs = FakeFs::new(cx.executor().clone());
2510 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
2511 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
2512 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2513 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2514 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2515
2516 // Make a new buffer with no backing file
2517 workspace
2518 .update(cx, |workspace, window, cx| {
2519 Editor::new_file(workspace, &Default::default(), window, cx)
2520 })
2521 .unwrap();
2522
2523 cx.executor().run_until_parked();
2524
2525 // "Save as" the buffer, creating a new backing file for it
2526 let save_task = workspace
2527 .update(cx, |workspace, window, cx| {
2528 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2529 })
2530 .unwrap();
2531
2532 cx.executor().run_until_parked();
2533 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
2534 save_task.await.unwrap();
2535
2536 // Rename the file
2537 select_path(&panel, "root/new", cx);
2538 assert_eq!(
2539 visible_entries_as_strings(&panel, 0..10, cx),
2540 &["v root", " new <== selected <== marked"]
2541 );
2542 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2543 panel.update_in(cx, |panel, window, cx| {
2544 panel
2545 .filename_editor
2546 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
2547 });
2548 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2549
2550 cx.executor().run_until_parked();
2551 assert_eq!(
2552 visible_entries_as_strings(&panel, 0..10, cx),
2553 &["v root", " newer <== selected"]
2554 );
2555
2556 workspace
2557 .update(cx, |workspace, window, cx| {
2558 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2559 })
2560 .unwrap()
2561 .await
2562 .unwrap();
2563
2564 cx.executor().run_until_parked();
2565 // assert that saving the file doesn't restore "new"
2566 assert_eq!(
2567 visible_entries_as_strings(&panel, 0..10, cx),
2568 &["v root", " newer <== selected"]
2569 );
2570}
2571
2572#[gpui::test]
2573#[cfg_attr(target_os = "windows", ignore)]
2574async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2575 init_test_with_editor(cx);
2576
2577 let fs = FakeFs::new(cx.executor().clone());
2578 fs.insert_tree(
2579 "/root1",
2580 json!({
2581 "dir1": {
2582 "file1.txt": "content 1",
2583 },
2584 }),
2585 )
2586 .await;
2587
2588 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2589 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2590 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2591 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2592
2593 toggle_expand_dir(&panel, "root1/dir1", cx);
2594
2595 assert_eq!(
2596 visible_entries_as_strings(&panel, 0..20, cx),
2597 &["v root1", " v dir1 <== selected", " file1.txt",],
2598 "Initial state with worktrees"
2599 );
2600
2601 select_path(&panel, "root1", cx);
2602 assert_eq!(
2603 visible_entries_as_strings(&panel, 0..20, cx),
2604 &["v root1 <== selected", " v dir1", " file1.txt",],
2605 );
2606
2607 // Rename root1 to new_root1
2608 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2609
2610 assert_eq!(
2611 visible_entries_as_strings(&panel, 0..20, cx),
2612 &[
2613 "v [EDITOR: 'root1'] <== selected",
2614 " v dir1",
2615 " file1.txt",
2616 ],
2617 );
2618
2619 let confirm = panel.update_in(cx, |panel, window, cx| {
2620 panel
2621 .filename_editor
2622 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
2623 panel.confirm_edit(window, cx).unwrap()
2624 });
2625 confirm.await.unwrap();
2626 assert_eq!(
2627 visible_entries_as_strings(&panel, 0..20, cx),
2628 &[
2629 "v new_root1 <== selected",
2630 " v dir1",
2631 " file1.txt",
2632 ],
2633 "Should update worktree name"
2634 );
2635
2636 // Ensure internal paths have been updated
2637 select_path(&panel, "new_root1/dir1/file1.txt", cx);
2638 assert_eq!(
2639 visible_entries_as_strings(&panel, 0..20, cx),
2640 &[
2641 "v new_root1",
2642 " v dir1",
2643 " file1.txt <== selected",
2644 ],
2645 "Files in renamed worktree are selectable"
2646 );
2647}
2648
2649#[gpui::test]
2650async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
2651 init_test_with_editor(cx);
2652 let fs = FakeFs::new(cx.executor().clone());
2653 fs.insert_tree(
2654 "/project_root",
2655 json!({
2656 "dir_1": {
2657 "nested_dir": {
2658 "file_a.py": "# File contents",
2659 }
2660 },
2661 "file_1.py": "# File contents",
2662 }),
2663 )
2664 .await;
2665
2666 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2667 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
2668 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2669 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2670 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2671 cx.update(|window, cx| {
2672 panel.update(cx, |this, cx| {
2673 this.select_next(&Default::default(), window, cx);
2674 this.expand_selected_entry(&Default::default(), window, cx);
2675 this.expand_selected_entry(&Default::default(), window, cx);
2676 this.select_next(&Default::default(), window, cx);
2677 this.expand_selected_entry(&Default::default(), window, cx);
2678 this.select_next(&Default::default(), window, cx);
2679 })
2680 });
2681 assert_eq!(
2682 visible_entries_as_strings(&panel, 0..10, cx),
2683 &[
2684 "v project_root",
2685 " v dir_1",
2686 " v nested_dir",
2687 " file_a.py <== selected",
2688 " file_1.py",
2689 ]
2690 );
2691 let modifiers_with_shift = gpui::Modifiers {
2692 shift: true,
2693 ..Default::default()
2694 };
2695 cx.simulate_modifiers_change(modifiers_with_shift);
2696 cx.update(|window, cx| {
2697 panel.update(cx, |this, cx| {
2698 this.select_next(&Default::default(), window, cx);
2699 })
2700 });
2701 assert_eq!(
2702 visible_entries_as_strings(&panel, 0..10, cx),
2703 &[
2704 "v project_root",
2705 " v dir_1",
2706 " v nested_dir",
2707 " file_a.py",
2708 " file_1.py <== selected <== marked",
2709 ]
2710 );
2711 cx.update(|window, cx| {
2712 panel.update(cx, |this, cx| {
2713 this.select_previous(&Default::default(), window, cx);
2714 })
2715 });
2716 assert_eq!(
2717 visible_entries_as_strings(&panel, 0..10, cx),
2718 &[
2719 "v project_root",
2720 " v dir_1",
2721 " v nested_dir",
2722 " file_a.py <== selected <== marked",
2723 " file_1.py <== marked",
2724 ]
2725 );
2726 cx.update(|window, cx| {
2727 panel.update(cx, |this, cx| {
2728 let drag = DraggedSelection {
2729 active_selection: this.selection.unwrap(),
2730 marked_selections: Arc::new(this.marked_entries.clone()),
2731 };
2732 let target_entry = this
2733 .project
2734 .read(cx)
2735 .entry_for_path(&(worktree_id, "").into(), cx)
2736 .unwrap();
2737 this.drag_onto(&drag, target_entry.id, false, window, cx);
2738 });
2739 });
2740 cx.run_until_parked();
2741 assert_eq!(
2742 visible_entries_as_strings(&panel, 0..10, cx),
2743 &[
2744 "v project_root",
2745 " v dir_1",
2746 " v nested_dir",
2747 " file_1.py <== marked",
2748 " file_a.py <== selected <== marked",
2749 ]
2750 );
2751 // ESC clears out all marks
2752 cx.update(|window, cx| {
2753 panel.update(cx, |this, cx| {
2754 this.cancel(&menu::Cancel, window, cx);
2755 })
2756 });
2757 assert_eq!(
2758 visible_entries_as_strings(&panel, 0..10, cx),
2759 &[
2760 "v project_root",
2761 " v dir_1",
2762 " v nested_dir",
2763 " file_1.py",
2764 " file_a.py <== selected",
2765 ]
2766 );
2767 // ESC clears out all marks
2768 cx.update(|window, cx| {
2769 panel.update(cx, |this, cx| {
2770 this.select_previous(&SelectPrevious, window, cx);
2771 this.select_next(&SelectNext, window, cx);
2772 })
2773 });
2774 assert_eq!(
2775 visible_entries_as_strings(&panel, 0..10, cx),
2776 &[
2777 "v project_root",
2778 " v dir_1",
2779 " v nested_dir",
2780 " file_1.py <== marked",
2781 " file_a.py <== selected <== marked",
2782 ]
2783 );
2784 cx.simulate_modifiers_change(Default::default());
2785 cx.update(|window, cx| {
2786 panel.update(cx, |this, cx| {
2787 this.cut(&Cut, window, cx);
2788 this.select_previous(&SelectPrevious, window, cx);
2789 this.select_previous(&SelectPrevious, window, cx);
2790
2791 this.paste(&Paste, window, cx);
2792 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
2793 })
2794 });
2795 cx.run_until_parked();
2796 assert_eq!(
2797 visible_entries_as_strings(&panel, 0..10, cx),
2798 &[
2799 "v project_root",
2800 " v dir_1",
2801 " v nested_dir",
2802 " file_1.py <== marked",
2803 " file_a.py <== selected <== marked",
2804 ]
2805 );
2806 cx.simulate_modifiers_change(modifiers_with_shift);
2807 cx.update(|window, cx| {
2808 panel.update(cx, |this, cx| {
2809 this.expand_selected_entry(&Default::default(), window, cx);
2810 this.select_next(&SelectNext, window, cx);
2811 this.select_next(&SelectNext, window, cx);
2812 })
2813 });
2814 submit_deletion(&panel, cx);
2815 assert_eq!(
2816 visible_entries_as_strings(&panel, 0..10, cx),
2817 &[
2818 "v project_root",
2819 " v dir_1",
2820 " v nested_dir <== selected",
2821 ]
2822 );
2823}
2824#[gpui::test]
2825async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2826 init_test_with_editor(cx);
2827 cx.update(|cx| {
2828 cx.update_global::<SettingsStore, _>(|store, cx| {
2829 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2830 worktree_settings.file_scan_exclusions = Some(Vec::new());
2831 });
2832 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2833 project_panel_settings.auto_reveal_entries = Some(false)
2834 });
2835 })
2836 });
2837
2838 let fs = FakeFs::new(cx.background_executor.clone());
2839 fs.insert_tree(
2840 "/project_root",
2841 json!({
2842 ".git": {},
2843 ".gitignore": "**/gitignored_dir",
2844 "dir_1": {
2845 "file_1.py": "# File 1_1 contents",
2846 "file_2.py": "# File 1_2 contents",
2847 "file_3.py": "# File 1_3 contents",
2848 "gitignored_dir": {
2849 "file_a.py": "# File contents",
2850 "file_b.py": "# File contents",
2851 "file_c.py": "# File contents",
2852 },
2853 },
2854 "dir_2": {
2855 "file_1.py": "# File 2_1 contents",
2856 "file_2.py": "# File 2_2 contents",
2857 "file_3.py": "# File 2_3 contents",
2858 }
2859 }),
2860 )
2861 .await;
2862
2863 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2864 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2865 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2866 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2867
2868 assert_eq!(
2869 visible_entries_as_strings(&panel, 0..20, cx),
2870 &[
2871 "v project_root",
2872 " > .git",
2873 " > dir_1",
2874 " > dir_2",
2875 " .gitignore",
2876 ]
2877 );
2878
2879 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2880 .expect("dir 1 file is not ignored and should have an entry");
2881 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2882 .expect("dir 2 file is not ignored and should have an entry");
2883 let gitignored_dir_file =
2884 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2885 assert_eq!(
2886 gitignored_dir_file, None,
2887 "File in the gitignored dir should not have an entry before its dir is toggled"
2888 );
2889
2890 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2891 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2892 cx.executor().run_until_parked();
2893 assert_eq!(
2894 visible_entries_as_strings(&panel, 0..20, cx),
2895 &[
2896 "v project_root",
2897 " > .git",
2898 " v dir_1",
2899 " v gitignored_dir <== selected",
2900 " file_a.py",
2901 " file_b.py",
2902 " file_c.py",
2903 " file_1.py",
2904 " file_2.py",
2905 " file_3.py",
2906 " > dir_2",
2907 " .gitignore",
2908 ],
2909 "Should show gitignored dir file list in the project panel"
2910 );
2911 let gitignored_dir_file =
2912 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2913 .expect("after gitignored dir got opened, a file entry should be present");
2914
2915 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2916 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2917 assert_eq!(
2918 visible_entries_as_strings(&panel, 0..20, cx),
2919 &[
2920 "v project_root",
2921 " > .git",
2922 " > dir_1 <== selected",
2923 " > dir_2",
2924 " .gitignore",
2925 ],
2926 "Should hide all dir contents again and prepare for the auto reveal test"
2927 );
2928
2929 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
2930 panel.update(cx, |panel, cx| {
2931 panel.project.update(cx, |_, cx| {
2932 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
2933 })
2934 });
2935 cx.run_until_parked();
2936 assert_eq!(
2937 visible_entries_as_strings(&panel, 0..20, cx),
2938 &[
2939 "v project_root",
2940 " > .git",
2941 " > dir_1 <== selected",
2942 " > dir_2",
2943 " .gitignore",
2944 ],
2945 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
2946 );
2947 }
2948
2949 cx.update(|_, cx| {
2950 cx.update_global::<SettingsStore, _>(|store, cx| {
2951 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2952 project_panel_settings.auto_reveal_entries = Some(true)
2953 });
2954 })
2955 });
2956
2957 panel.update(cx, |panel, cx| {
2958 panel.project.update(cx, |_, cx| {
2959 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
2960 })
2961 });
2962 cx.run_until_parked();
2963 assert_eq!(
2964 visible_entries_as_strings(&panel, 0..20, cx),
2965 &[
2966 "v project_root",
2967 " > .git",
2968 " v dir_1",
2969 " > gitignored_dir",
2970 " file_1.py <== selected <== marked",
2971 " file_2.py",
2972 " file_3.py",
2973 " > dir_2",
2974 " .gitignore",
2975 ],
2976 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
2977 );
2978
2979 panel.update(cx, |panel, cx| {
2980 panel.project.update(cx, |_, cx| {
2981 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
2982 })
2983 });
2984 cx.run_until_parked();
2985 assert_eq!(
2986 visible_entries_as_strings(&panel, 0..20, cx),
2987 &[
2988 "v project_root",
2989 " > .git",
2990 " v dir_1",
2991 " > gitignored_dir",
2992 " file_1.py",
2993 " file_2.py",
2994 " file_3.py",
2995 " v dir_2",
2996 " file_1.py <== selected <== marked",
2997 " file_2.py",
2998 " file_3.py",
2999 " .gitignore",
3000 ],
3001 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3002 );
3003
3004 panel.update(cx, |panel, cx| {
3005 panel.project.update(cx, |_, cx| {
3006 cx.emit(project::Event::ActiveEntryChanged(Some(
3007 gitignored_dir_file,
3008 )))
3009 })
3010 });
3011 cx.run_until_parked();
3012 assert_eq!(
3013 visible_entries_as_strings(&panel, 0..20, cx),
3014 &[
3015 "v project_root",
3016 " > .git",
3017 " v dir_1",
3018 " > gitignored_dir",
3019 " file_1.py",
3020 " file_2.py",
3021 " file_3.py",
3022 " v dir_2",
3023 " file_1.py <== selected <== marked",
3024 " file_2.py",
3025 " file_3.py",
3026 " .gitignore",
3027 ],
3028 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3029 );
3030
3031 panel.update(cx, |panel, cx| {
3032 panel.project.update(cx, |_, cx| {
3033 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3034 })
3035 });
3036 cx.run_until_parked();
3037 assert_eq!(
3038 visible_entries_as_strings(&panel, 0..20, cx),
3039 &[
3040 "v project_root",
3041 " > .git",
3042 " v dir_1",
3043 " v gitignored_dir",
3044 " file_a.py <== selected <== marked",
3045 " file_b.py",
3046 " file_c.py",
3047 " file_1.py",
3048 " file_2.py",
3049 " file_3.py",
3050 " v dir_2",
3051 " file_1.py",
3052 " file_2.py",
3053 " file_3.py",
3054 " .gitignore",
3055 ],
3056 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3057 );
3058}
3059
3060#[gpui::test]
3061async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3062 init_test_with_editor(cx);
3063 cx.update(|cx| {
3064 cx.update_global::<SettingsStore, _>(|store, cx| {
3065 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3066 worktree_settings.file_scan_exclusions = Some(Vec::new());
3067 worktree_settings.file_scan_inclusions =
3068 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3069 });
3070 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3071 project_panel_settings.auto_reveal_entries = Some(false)
3072 });
3073 })
3074 });
3075
3076 let fs = FakeFs::new(cx.background_executor.clone());
3077 fs.insert_tree(
3078 "/project_root",
3079 json!({
3080 ".git": {},
3081 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3082 "dir_1": {
3083 "file_1.py": "# File 1_1 contents",
3084 "file_2.py": "# File 1_2 contents",
3085 "file_3.py": "# File 1_3 contents",
3086 "gitignored_dir": {
3087 "file_a.py": "# File contents",
3088 "file_b.py": "# File contents",
3089 "file_c.py": "# File contents",
3090 },
3091 },
3092 "dir_2": {
3093 "file_1.py": "# File 2_1 contents",
3094 "file_2.py": "# File 2_2 contents",
3095 "file_3.py": "# File 2_3 contents",
3096 },
3097 "always_included_but_ignored_dir": {
3098 "file_a.py": "# File contents",
3099 "file_b.py": "# File contents",
3100 "file_c.py": "# File contents",
3101 },
3102 }),
3103 )
3104 .await;
3105
3106 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3107 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3108 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3109 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3110
3111 assert_eq!(
3112 visible_entries_as_strings(&panel, 0..20, cx),
3113 &[
3114 "v project_root",
3115 " > .git",
3116 " > always_included_but_ignored_dir",
3117 " > dir_1",
3118 " > dir_2",
3119 " .gitignore",
3120 ]
3121 );
3122
3123 let gitignored_dir_file =
3124 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3125 let always_included_but_ignored_dir_file = find_project_entry(
3126 &panel,
3127 "project_root/always_included_but_ignored_dir/file_a.py",
3128 cx,
3129 )
3130 .expect("file that is .gitignored but set to always be included should have an entry");
3131 assert_eq!(
3132 gitignored_dir_file, None,
3133 "File in the gitignored dir should not have an entry unless its directory is toggled"
3134 );
3135
3136 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3137 cx.run_until_parked();
3138 cx.update(|_, cx| {
3139 cx.update_global::<SettingsStore, _>(|store, cx| {
3140 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3141 project_panel_settings.auto_reveal_entries = Some(true)
3142 });
3143 })
3144 });
3145
3146 panel.update(cx, |panel, cx| {
3147 panel.project.update(cx, |_, cx| {
3148 cx.emit(project::Event::ActiveEntryChanged(Some(
3149 always_included_but_ignored_dir_file,
3150 )))
3151 })
3152 });
3153 cx.run_until_parked();
3154
3155 assert_eq!(
3156 visible_entries_as_strings(&panel, 0..20, cx),
3157 &[
3158 "v project_root",
3159 " > .git",
3160 " v always_included_but_ignored_dir",
3161 " file_a.py <== selected <== marked",
3162 " file_b.py",
3163 " file_c.py",
3164 " v dir_1",
3165 " > gitignored_dir",
3166 " file_1.py",
3167 " file_2.py",
3168 " file_3.py",
3169 " > dir_2",
3170 " .gitignore",
3171 ],
3172 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3173 );
3174}
3175
3176#[gpui::test]
3177async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3178 init_test_with_editor(cx);
3179 cx.update(|cx| {
3180 cx.update_global::<SettingsStore, _>(|store, cx| {
3181 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3182 worktree_settings.file_scan_exclusions = Some(Vec::new());
3183 });
3184 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3185 project_panel_settings.auto_reveal_entries = Some(false)
3186 });
3187 })
3188 });
3189
3190 let fs = FakeFs::new(cx.background_executor.clone());
3191 fs.insert_tree(
3192 "/project_root",
3193 json!({
3194 ".git": {},
3195 ".gitignore": "**/gitignored_dir",
3196 "dir_1": {
3197 "file_1.py": "# File 1_1 contents",
3198 "file_2.py": "# File 1_2 contents",
3199 "file_3.py": "# File 1_3 contents",
3200 "gitignored_dir": {
3201 "file_a.py": "# File contents",
3202 "file_b.py": "# File contents",
3203 "file_c.py": "# File contents",
3204 },
3205 },
3206 "dir_2": {
3207 "file_1.py": "# File 2_1 contents",
3208 "file_2.py": "# File 2_2 contents",
3209 "file_3.py": "# File 2_3 contents",
3210 }
3211 }),
3212 )
3213 .await;
3214
3215 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3216 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3217 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3218 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3219
3220 assert_eq!(
3221 visible_entries_as_strings(&panel, 0..20, cx),
3222 &[
3223 "v project_root",
3224 " > .git",
3225 " > dir_1",
3226 " > dir_2",
3227 " .gitignore",
3228 ]
3229 );
3230
3231 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3232 .expect("dir 1 file is not ignored and should have an entry");
3233 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3234 .expect("dir 2 file is not ignored and should have an entry");
3235 let gitignored_dir_file =
3236 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3237 assert_eq!(
3238 gitignored_dir_file, None,
3239 "File in the gitignored dir should not have an entry before its dir is toggled"
3240 );
3241
3242 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3243 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3244 cx.run_until_parked();
3245 assert_eq!(
3246 visible_entries_as_strings(&panel, 0..20, cx),
3247 &[
3248 "v project_root",
3249 " > .git",
3250 " v dir_1",
3251 " v gitignored_dir <== selected",
3252 " file_a.py",
3253 " file_b.py",
3254 " file_c.py",
3255 " file_1.py",
3256 " file_2.py",
3257 " file_3.py",
3258 " > dir_2",
3259 " .gitignore",
3260 ],
3261 "Should show gitignored dir file list in the project panel"
3262 );
3263 let gitignored_dir_file =
3264 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3265 .expect("after gitignored dir got opened, a file entry should be present");
3266
3267 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3268 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3269 assert_eq!(
3270 visible_entries_as_strings(&panel, 0..20, cx),
3271 &[
3272 "v project_root",
3273 " > .git",
3274 " > dir_1 <== selected",
3275 " > dir_2",
3276 " .gitignore",
3277 ],
3278 "Should hide all dir contents again and prepare for the explicit reveal test"
3279 );
3280
3281 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3282 panel.update(cx, |panel, cx| {
3283 panel.project.update(cx, |_, cx| {
3284 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3285 })
3286 });
3287 cx.run_until_parked();
3288 assert_eq!(
3289 visible_entries_as_strings(&panel, 0..20, cx),
3290 &[
3291 "v project_root",
3292 " > .git",
3293 " > dir_1 <== selected",
3294 " > dir_2",
3295 " .gitignore",
3296 ],
3297 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3298 );
3299 }
3300
3301 panel.update(cx, |panel, cx| {
3302 panel.project.update(cx, |_, cx| {
3303 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3304 })
3305 });
3306 cx.run_until_parked();
3307 assert_eq!(
3308 visible_entries_as_strings(&panel, 0..20, cx),
3309 &[
3310 "v project_root",
3311 " > .git",
3312 " v dir_1",
3313 " > gitignored_dir",
3314 " file_1.py <== selected <== marked",
3315 " file_2.py",
3316 " file_3.py",
3317 " > dir_2",
3318 " .gitignore",
3319 ],
3320 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3321 );
3322
3323 panel.update(cx, |panel, cx| {
3324 panel.project.update(cx, |_, cx| {
3325 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3326 })
3327 });
3328 cx.run_until_parked();
3329 assert_eq!(
3330 visible_entries_as_strings(&panel, 0..20, cx),
3331 &[
3332 "v project_root",
3333 " > .git",
3334 " v dir_1",
3335 " > gitignored_dir",
3336 " file_1.py",
3337 " file_2.py",
3338 " file_3.py",
3339 " v dir_2",
3340 " file_1.py <== selected <== marked",
3341 " file_2.py",
3342 " file_3.py",
3343 " .gitignore",
3344 ],
3345 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3346 );
3347
3348 panel.update(cx, |panel, cx| {
3349 panel.project.update(cx, |_, cx| {
3350 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3351 })
3352 });
3353 cx.run_until_parked();
3354 assert_eq!(
3355 visible_entries_as_strings(&panel, 0..20, cx),
3356 &[
3357 "v project_root",
3358 " > .git",
3359 " v dir_1",
3360 " v gitignored_dir",
3361 " file_a.py <== selected <== marked",
3362 " file_b.py",
3363 " file_c.py",
3364 " file_1.py",
3365 " file_2.py",
3366 " file_3.py",
3367 " v dir_2",
3368 " file_1.py",
3369 " file_2.py",
3370 " file_3.py",
3371 " .gitignore",
3372 ],
3373 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3374 );
3375}
3376
3377#[gpui::test]
3378async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
3379 init_test(cx);
3380 cx.update(|cx| {
3381 cx.update_global::<SettingsStore, _>(|store, cx| {
3382 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
3383 project_settings.file_scan_exclusions =
3384 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
3385 });
3386 });
3387 });
3388
3389 cx.update(|cx| {
3390 register_project_item::<TestProjectItemView>(cx);
3391 });
3392
3393 let fs = FakeFs::new(cx.executor().clone());
3394 fs.insert_tree(
3395 "/root1",
3396 json!({
3397 ".dockerignore": "",
3398 ".git": {
3399 "HEAD": "",
3400 },
3401 }),
3402 )
3403 .await;
3404
3405 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3406 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3407 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3408 let panel = workspace
3409 .update(cx, |workspace, window, cx| {
3410 let panel = ProjectPanel::new(workspace, window, cx);
3411 workspace.add_panel(panel.clone(), window, cx);
3412 panel
3413 })
3414 .unwrap();
3415
3416 select_path(&panel, "root1", cx);
3417 assert_eq!(
3418 visible_entries_as_strings(&panel, 0..10, cx),
3419 &["v root1 <== selected", " .dockerignore",]
3420 );
3421 workspace
3422 .update(cx, |workspace, _, cx| {
3423 assert!(
3424 workspace.active_item(cx).is_none(),
3425 "Should have no active items in the beginning"
3426 );
3427 })
3428 .unwrap();
3429
3430 let excluded_file_path = ".git/COMMIT_EDITMSG";
3431 let excluded_dir_path = "excluded_dir";
3432
3433 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3434 panel.update_in(cx, |panel, window, cx| {
3435 assert!(panel.filename_editor.read(cx).is_focused(window));
3436 });
3437 panel
3438 .update_in(cx, |panel, window, cx| {
3439 panel.filename_editor.update(cx, |editor, cx| {
3440 editor.set_text(excluded_file_path, window, cx)
3441 });
3442 panel.confirm_edit(window, cx).unwrap()
3443 })
3444 .await
3445 .unwrap();
3446
3447 assert_eq!(
3448 visible_entries_as_strings(&panel, 0..13, cx),
3449 &["v root1", " .dockerignore"],
3450 "Excluded dir should not be shown after opening a file in it"
3451 );
3452 panel.update_in(cx, |panel, window, cx| {
3453 assert!(
3454 !panel.filename_editor.read(cx).is_focused(window),
3455 "Should have closed the file name editor"
3456 );
3457 });
3458 workspace
3459 .update(cx, |workspace, _, cx| {
3460 let active_entry_path = workspace
3461 .active_item(cx)
3462 .expect("should have opened and activated the excluded item")
3463 .act_as::<TestProjectItemView>(cx)
3464 .expect("should have opened the corresponding project item for the excluded item")
3465 .read(cx)
3466 .path
3467 .clone();
3468 assert_eq!(
3469 active_entry_path.path.as_ref(),
3470 Path::new(excluded_file_path),
3471 "Should open the excluded file"
3472 );
3473
3474 assert!(
3475 workspace.notification_ids().is_empty(),
3476 "Should have no notifications after opening an excluded file"
3477 );
3478 })
3479 .unwrap();
3480 assert!(
3481 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
3482 "Should have created the excluded file"
3483 );
3484
3485 select_path(&panel, "root1", cx);
3486 panel.update_in(cx, |panel, window, cx| {
3487 panel.new_directory(&NewDirectory, window, cx)
3488 });
3489 panel.update_in(cx, |panel, window, cx| {
3490 assert!(panel.filename_editor.read(cx).is_focused(window));
3491 });
3492 panel
3493 .update_in(cx, |panel, window, cx| {
3494 panel.filename_editor.update(cx, |editor, cx| {
3495 editor.set_text(excluded_file_path, window, cx)
3496 });
3497 panel.confirm_edit(window, cx).unwrap()
3498 })
3499 .await
3500 .unwrap();
3501
3502 assert_eq!(
3503 visible_entries_as_strings(&panel, 0..13, cx),
3504 &["v root1", " .dockerignore"],
3505 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
3506 );
3507 panel.update_in(cx, |panel, window, cx| {
3508 assert!(
3509 !panel.filename_editor.read(cx).is_focused(window),
3510 "Should have closed the file name editor"
3511 );
3512 });
3513 workspace
3514 .update(cx, |workspace, _, cx| {
3515 let notifications = workspace.notification_ids();
3516 assert_eq!(
3517 notifications.len(),
3518 1,
3519 "Should receive one notification with the error message"
3520 );
3521 workspace.dismiss_notification(notifications.first().unwrap(), cx);
3522 assert!(workspace.notification_ids().is_empty());
3523 })
3524 .unwrap();
3525
3526 select_path(&panel, "root1", cx);
3527 panel.update_in(cx, |panel, window, cx| {
3528 panel.new_directory(&NewDirectory, window, cx)
3529 });
3530 panel.update_in(cx, |panel, window, cx| {
3531 assert!(panel.filename_editor.read(cx).is_focused(window));
3532 });
3533 panel
3534 .update_in(cx, |panel, window, cx| {
3535 panel.filename_editor.update(cx, |editor, cx| {
3536 editor.set_text(excluded_dir_path, window, cx)
3537 });
3538 panel.confirm_edit(window, cx).unwrap()
3539 })
3540 .await
3541 .unwrap();
3542
3543 assert_eq!(
3544 visible_entries_as_strings(&panel, 0..13, cx),
3545 &["v root1", " .dockerignore"],
3546 "Should not change the project panel after trying to create an excluded directory"
3547 );
3548 panel.update_in(cx, |panel, window, cx| {
3549 assert!(
3550 !panel.filename_editor.read(cx).is_focused(window),
3551 "Should have closed the file name editor"
3552 );
3553 });
3554 workspace
3555 .update(cx, |workspace, _, cx| {
3556 let notifications = workspace.notification_ids();
3557 assert_eq!(
3558 notifications.len(),
3559 1,
3560 "Should receive one notification explaining that no directory is actually shown"
3561 );
3562 workspace.dismiss_notification(notifications.first().unwrap(), cx);
3563 assert!(workspace.notification_ids().is_empty());
3564 })
3565 .unwrap();
3566 assert!(
3567 fs.is_dir(Path::new("/root1/excluded_dir")).await,
3568 "Should have created the excluded directory"
3569 );
3570}
3571
3572#[gpui::test]
3573async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
3574 init_test_with_editor(cx);
3575
3576 let fs = FakeFs::new(cx.executor().clone());
3577 fs.insert_tree(
3578 "/src",
3579 json!({
3580 "test": {
3581 "first.rs": "// First Rust file",
3582 "second.rs": "// Second Rust file",
3583 "third.rs": "// Third Rust file",
3584 }
3585 }),
3586 )
3587 .await;
3588
3589 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3590 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3591 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3592 let panel = workspace
3593 .update(cx, |workspace, window, cx| {
3594 let panel = ProjectPanel::new(workspace, window, cx);
3595 workspace.add_panel(panel.clone(), window, cx);
3596 panel
3597 })
3598 .unwrap();
3599
3600 select_path(&panel, "src/", cx);
3601 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3602 cx.executor().run_until_parked();
3603 assert_eq!(
3604 visible_entries_as_strings(&panel, 0..10, cx),
3605 &[
3606 //
3607 "v src <== selected",
3608 " > test"
3609 ]
3610 );
3611 panel.update_in(cx, |panel, window, cx| {
3612 panel.new_directory(&NewDirectory, window, cx)
3613 });
3614 panel.update_in(cx, |panel, window, cx| {
3615 assert!(panel.filename_editor.read(cx).is_focused(window));
3616 });
3617 assert_eq!(
3618 visible_entries_as_strings(&panel, 0..10, cx),
3619 &[
3620 //
3621 "v src",
3622 " > [EDITOR: ''] <== selected",
3623 " > test"
3624 ]
3625 );
3626
3627 panel.update_in(cx, |panel, window, cx| {
3628 panel.cancel(&menu::Cancel, window, cx)
3629 });
3630 assert_eq!(
3631 visible_entries_as_strings(&panel, 0..10, cx),
3632 &[
3633 //
3634 "v src <== selected",
3635 " > test"
3636 ]
3637 );
3638}
3639
3640#[gpui::test]
3641async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
3642 init_test_with_editor(cx);
3643
3644 let fs = FakeFs::new(cx.executor().clone());
3645 fs.insert_tree(
3646 "/root",
3647 json!({
3648 "dir1": {
3649 "subdir1": {},
3650 "file1.txt": "",
3651 "file2.txt": "",
3652 },
3653 "dir2": {
3654 "subdir2": {},
3655 "file3.txt": "",
3656 "file4.txt": "",
3657 },
3658 "file5.txt": "",
3659 "file6.txt": "",
3660 }),
3661 )
3662 .await;
3663
3664 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3665 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3666 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3667 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3668
3669 toggle_expand_dir(&panel, "root/dir1", cx);
3670 toggle_expand_dir(&panel, "root/dir2", cx);
3671
3672 // Test Case 1: Delete middle file in directory
3673 select_path(&panel, "root/dir1/file1.txt", cx);
3674 assert_eq!(
3675 visible_entries_as_strings(&panel, 0..15, cx),
3676 &[
3677 "v root",
3678 " v dir1",
3679 " > subdir1",
3680 " file1.txt <== selected",
3681 " file2.txt",
3682 " v dir2",
3683 " > subdir2",
3684 " file3.txt",
3685 " file4.txt",
3686 " file5.txt",
3687 " file6.txt",
3688 ],
3689 "Initial state before deleting middle file"
3690 );
3691
3692 submit_deletion(&panel, cx);
3693 assert_eq!(
3694 visible_entries_as_strings(&panel, 0..15, cx),
3695 &[
3696 "v root",
3697 " v dir1",
3698 " > subdir1",
3699 " file2.txt <== selected",
3700 " v dir2",
3701 " > subdir2",
3702 " file3.txt",
3703 " file4.txt",
3704 " file5.txt",
3705 " file6.txt",
3706 ],
3707 "Should select next file after deleting middle file"
3708 );
3709
3710 // Test Case 2: Delete last file in directory
3711 submit_deletion(&panel, cx);
3712 assert_eq!(
3713 visible_entries_as_strings(&panel, 0..15, cx),
3714 &[
3715 "v root",
3716 " v dir1",
3717 " > subdir1 <== selected",
3718 " v dir2",
3719 " > subdir2",
3720 " file3.txt",
3721 " file4.txt",
3722 " file5.txt",
3723 " file6.txt",
3724 ],
3725 "Should select next directory when last file is deleted"
3726 );
3727
3728 // Test Case 3: Delete root level file
3729 select_path(&panel, "root/file6.txt", cx);
3730 assert_eq!(
3731 visible_entries_as_strings(&panel, 0..15, cx),
3732 &[
3733 "v root",
3734 " v dir1",
3735 " > subdir1",
3736 " v dir2",
3737 " > subdir2",
3738 " file3.txt",
3739 " file4.txt",
3740 " file5.txt",
3741 " file6.txt <== selected",
3742 ],
3743 "Initial state before deleting root level file"
3744 );
3745
3746 submit_deletion(&panel, cx);
3747 assert_eq!(
3748 visible_entries_as_strings(&panel, 0..15, cx),
3749 &[
3750 "v root",
3751 " v dir1",
3752 " > subdir1",
3753 " v dir2",
3754 " > subdir2",
3755 " file3.txt",
3756 " file4.txt",
3757 " file5.txt <== selected",
3758 ],
3759 "Should select prev entry at root level"
3760 );
3761}
3762
3763#[gpui::test]
3764async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
3765 init_test_with_editor(cx);
3766
3767 let fs = FakeFs::new(cx.executor().clone());
3768 fs.insert_tree(
3769 path!("/root"),
3770 json!({
3771 "aa": "// Testing 1",
3772 "bb": "// Testing 2",
3773 "cc": "// Testing 3",
3774 "dd": "// Testing 4",
3775 "ee": "// Testing 5",
3776 "ff": "// Testing 6",
3777 "gg": "// Testing 7",
3778 "hh": "// Testing 8",
3779 "ii": "// Testing 8",
3780 ".gitignore": "bb\ndd\nee\nff\nii\n'",
3781 }),
3782 )
3783 .await;
3784
3785 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3786 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3787 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3788
3789 // Test 1: Auto selection with one gitignored file next to the deleted file
3790 cx.update(|_, cx| {
3791 let settings = *ProjectPanelSettings::get_global(cx);
3792 ProjectPanelSettings::override_global(
3793 ProjectPanelSettings {
3794 hide_gitignore: true,
3795 ..settings
3796 },
3797 cx,
3798 );
3799 });
3800
3801 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3802
3803 select_path(&panel, "root/aa", cx);
3804 assert_eq!(
3805 visible_entries_as_strings(&panel, 0..10, cx),
3806 &[
3807 "v root",
3808 " .gitignore",
3809 " aa <== selected",
3810 " cc",
3811 " gg",
3812 " hh"
3813 ],
3814 "Initial state should hide files on .gitignore"
3815 );
3816
3817 submit_deletion(&panel, cx);
3818
3819 assert_eq!(
3820 visible_entries_as_strings(&panel, 0..10, cx),
3821 &[
3822 "v root",
3823 " .gitignore",
3824 " cc <== selected",
3825 " gg",
3826 " hh"
3827 ],
3828 "Should select next entry not on .gitignore"
3829 );
3830
3831 // Test 2: Auto selection with many gitignored files next to the deleted file
3832 submit_deletion(&panel, cx);
3833 assert_eq!(
3834 visible_entries_as_strings(&panel, 0..10, cx),
3835 &[
3836 "v root",
3837 " .gitignore",
3838 " gg <== selected",
3839 " hh"
3840 ],
3841 "Should select next entry not on .gitignore"
3842 );
3843
3844 // Test 3: Auto selection of entry before deleted file
3845 select_path(&panel, "root/hh", cx);
3846 assert_eq!(
3847 visible_entries_as_strings(&panel, 0..10, cx),
3848 &[
3849 "v root",
3850 " .gitignore",
3851 " gg",
3852 " hh <== selected"
3853 ],
3854 "Should select next entry not on .gitignore"
3855 );
3856 submit_deletion(&panel, cx);
3857 assert_eq!(
3858 visible_entries_as_strings(&panel, 0..10, cx),
3859 &["v root", " .gitignore", " gg <== selected"],
3860 "Should select next entry not on .gitignore"
3861 );
3862}
3863
3864#[gpui::test]
3865async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
3866 init_test_with_editor(cx);
3867
3868 let fs = FakeFs::new(cx.executor().clone());
3869 fs.insert_tree(
3870 path!("/root"),
3871 json!({
3872 "dir1": {
3873 "file1": "// Testing",
3874 "file2": "// Testing",
3875 "file3": "// Testing"
3876 },
3877 "aa": "// Testing",
3878 ".gitignore": "file1\nfile3\n",
3879 }),
3880 )
3881 .await;
3882
3883 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
3884 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3885 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3886
3887 cx.update(|_, cx| {
3888 let settings = *ProjectPanelSettings::get_global(cx);
3889 ProjectPanelSettings::override_global(
3890 ProjectPanelSettings {
3891 hide_gitignore: true,
3892 ..settings
3893 },
3894 cx,
3895 );
3896 });
3897
3898 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3899
3900 // Test 1: Visible items should exclude files on gitignore
3901 toggle_expand_dir(&panel, "root/dir1", cx);
3902 select_path(&panel, "root/dir1/file2", cx);
3903 assert_eq!(
3904 visible_entries_as_strings(&panel, 0..10, cx),
3905 &[
3906 "v root",
3907 " v dir1",
3908 " file2 <== selected",
3909 " .gitignore",
3910 " aa"
3911 ],
3912 "Initial state should hide files on .gitignore"
3913 );
3914 submit_deletion(&panel, cx);
3915
3916 // Test 2: Auto selection should go to the parent
3917 assert_eq!(
3918 visible_entries_as_strings(&panel, 0..10, cx),
3919 &[
3920 "v root",
3921 " v dir1 <== selected",
3922 " .gitignore",
3923 " aa"
3924 ],
3925 "Initial state should hide files on .gitignore"
3926 );
3927}
3928
3929#[gpui::test]
3930async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
3931 init_test_with_editor(cx);
3932
3933 let fs = FakeFs::new(cx.executor().clone());
3934 fs.insert_tree(
3935 "/root",
3936 json!({
3937 "dir1": {
3938 "subdir1": {
3939 "a.txt": "",
3940 "b.txt": ""
3941 },
3942 "file1.txt": "",
3943 },
3944 "dir2": {
3945 "subdir2": {
3946 "c.txt": "",
3947 "d.txt": ""
3948 },
3949 "file2.txt": "",
3950 },
3951 "file3.txt": "",
3952 }),
3953 )
3954 .await;
3955
3956 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3957 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3958 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3959 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3960
3961 toggle_expand_dir(&panel, "root/dir1", cx);
3962 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3963 toggle_expand_dir(&panel, "root/dir2", cx);
3964 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
3965
3966 // Test Case 1: Select and delete nested directory with parent
3967 cx.simulate_modifiers_change(gpui::Modifiers {
3968 control: true,
3969 ..Default::default()
3970 });
3971 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
3972 select_path_with_mark(&panel, "root/dir1", cx);
3973
3974 assert_eq!(
3975 visible_entries_as_strings(&panel, 0..15, cx),
3976 &[
3977 "v root",
3978 " v dir1 <== selected <== marked",
3979 " v subdir1 <== marked",
3980 " a.txt",
3981 " b.txt",
3982 " file1.txt",
3983 " v dir2",
3984 " v subdir2",
3985 " c.txt",
3986 " d.txt",
3987 " file2.txt",
3988 " file3.txt",
3989 ],
3990 "Initial state before deleting nested directory with parent"
3991 );
3992
3993 submit_deletion(&panel, cx);
3994 assert_eq!(
3995 visible_entries_as_strings(&panel, 0..15, cx),
3996 &[
3997 "v root",
3998 " v dir2 <== selected",
3999 " v subdir2",
4000 " c.txt",
4001 " d.txt",
4002 " file2.txt",
4003 " file3.txt",
4004 ],
4005 "Should select next directory after deleting directory with parent"
4006 );
4007
4008 // Test Case 2: Select mixed files and directories across levels
4009 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4010 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4011 select_path_with_mark(&panel, "root/file3.txt", cx);
4012
4013 assert_eq!(
4014 visible_entries_as_strings(&panel, 0..15, cx),
4015 &[
4016 "v root",
4017 " v dir2",
4018 " v subdir2",
4019 " c.txt <== marked",
4020 " d.txt",
4021 " file2.txt <== marked",
4022 " file3.txt <== selected <== marked",
4023 ],
4024 "Initial state before deleting"
4025 );
4026
4027 submit_deletion(&panel, cx);
4028 assert_eq!(
4029 visible_entries_as_strings(&panel, 0..15, cx),
4030 &[
4031 "v root",
4032 " v dir2 <== selected",
4033 " v subdir2",
4034 " d.txt",
4035 ],
4036 "Should select sibling directory"
4037 );
4038}
4039
4040#[gpui::test]
4041async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4042 init_test_with_editor(cx);
4043
4044 let fs = FakeFs::new(cx.executor().clone());
4045 fs.insert_tree(
4046 "/root",
4047 json!({
4048 "dir1": {
4049 "subdir1": {
4050 "a.txt": "",
4051 "b.txt": ""
4052 },
4053 "file1.txt": "",
4054 },
4055 "dir2": {
4056 "subdir2": {
4057 "c.txt": "",
4058 "d.txt": ""
4059 },
4060 "file2.txt": "",
4061 },
4062 "file3.txt": "",
4063 "file4.txt": "",
4064 }),
4065 )
4066 .await;
4067
4068 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4069 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4070 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4071 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4072
4073 toggle_expand_dir(&panel, "root/dir1", cx);
4074 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4075 toggle_expand_dir(&panel, "root/dir2", cx);
4076 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4077
4078 // Test Case 1: Select all root files and directories
4079 cx.simulate_modifiers_change(gpui::Modifiers {
4080 control: true,
4081 ..Default::default()
4082 });
4083 select_path_with_mark(&panel, "root/dir1", cx);
4084 select_path_with_mark(&panel, "root/dir2", cx);
4085 select_path_with_mark(&panel, "root/file3.txt", cx);
4086 select_path_with_mark(&panel, "root/file4.txt", cx);
4087 assert_eq!(
4088 visible_entries_as_strings(&panel, 0..20, cx),
4089 &[
4090 "v root",
4091 " v dir1 <== marked",
4092 " v subdir1",
4093 " a.txt",
4094 " b.txt",
4095 " file1.txt",
4096 " v dir2 <== marked",
4097 " v subdir2",
4098 " c.txt",
4099 " d.txt",
4100 " file2.txt",
4101 " file3.txt <== marked",
4102 " file4.txt <== selected <== marked",
4103 ],
4104 "State before deleting all contents"
4105 );
4106
4107 submit_deletion(&panel, cx);
4108 assert_eq!(
4109 visible_entries_as_strings(&panel, 0..20, cx),
4110 &["v root <== selected"],
4111 "Only empty root directory should remain after deleting all contents"
4112 );
4113}
4114
4115#[gpui::test]
4116async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4117 init_test_with_editor(cx);
4118
4119 let fs = FakeFs::new(cx.executor().clone());
4120 fs.insert_tree(
4121 "/root",
4122 json!({
4123 "dir1": {
4124 "subdir1": {
4125 "file_a.txt": "content a",
4126 "file_b.txt": "content b",
4127 },
4128 "subdir2": {
4129 "file_c.txt": "content c",
4130 },
4131 "file1.txt": "content 1",
4132 },
4133 "dir2": {
4134 "file2.txt": "content 2",
4135 },
4136 }),
4137 )
4138 .await;
4139
4140 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4141 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4142 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4143 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4144
4145 toggle_expand_dir(&panel, "root/dir1", cx);
4146 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4147 toggle_expand_dir(&panel, "root/dir2", cx);
4148 cx.simulate_modifiers_change(gpui::Modifiers {
4149 control: true,
4150 ..Default::default()
4151 });
4152
4153 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4154 select_path_with_mark(&panel, "root/dir1", cx);
4155 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4156 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4157
4158 assert_eq!(
4159 visible_entries_as_strings(&panel, 0..20, cx),
4160 &[
4161 "v root",
4162 " v dir1 <== marked",
4163 " v subdir1 <== marked",
4164 " file_a.txt <== selected <== marked",
4165 " file_b.txt",
4166 " > subdir2",
4167 " file1.txt",
4168 " v dir2",
4169 " file2.txt",
4170 ],
4171 "State with parent dir, subdir, and file selected"
4172 );
4173 submit_deletion(&panel, cx);
4174 assert_eq!(
4175 visible_entries_as_strings(&panel, 0..20, cx),
4176 &["v root", " v dir2 <== selected", " file2.txt",],
4177 "Only dir2 should remain after deletion"
4178 );
4179}
4180
4181#[gpui::test]
4182async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4183 init_test_with_editor(cx);
4184
4185 let fs = FakeFs::new(cx.executor().clone());
4186 // First worktree
4187 fs.insert_tree(
4188 "/root1",
4189 json!({
4190 "dir1": {
4191 "file1.txt": "content 1",
4192 "file2.txt": "content 2",
4193 },
4194 "dir2": {
4195 "file3.txt": "content 3",
4196 },
4197 }),
4198 )
4199 .await;
4200
4201 // Second worktree
4202 fs.insert_tree(
4203 "/root2",
4204 json!({
4205 "dir3": {
4206 "file4.txt": "content 4",
4207 "file5.txt": "content 5",
4208 },
4209 "file6.txt": "content 6",
4210 }),
4211 )
4212 .await;
4213
4214 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4215 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4216 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4217 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4218
4219 // Expand all directories for testing
4220 toggle_expand_dir(&panel, "root1/dir1", cx);
4221 toggle_expand_dir(&panel, "root1/dir2", cx);
4222 toggle_expand_dir(&panel, "root2/dir3", cx);
4223
4224 // Test Case 1: Delete files across different worktrees
4225 cx.simulate_modifiers_change(gpui::Modifiers {
4226 control: true,
4227 ..Default::default()
4228 });
4229 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4230 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4231
4232 assert_eq!(
4233 visible_entries_as_strings(&panel, 0..20, cx),
4234 &[
4235 "v root1",
4236 " v dir1",
4237 " file1.txt <== marked",
4238 " file2.txt",
4239 " v dir2",
4240 " file3.txt",
4241 "v root2",
4242 " v dir3",
4243 " file4.txt <== selected <== marked",
4244 " file5.txt",
4245 " file6.txt",
4246 ],
4247 "Initial state with files selected from different worktrees"
4248 );
4249
4250 submit_deletion(&panel, cx);
4251 assert_eq!(
4252 visible_entries_as_strings(&panel, 0..20, cx),
4253 &[
4254 "v root1",
4255 " v dir1",
4256 " file2.txt",
4257 " v dir2",
4258 " file3.txt",
4259 "v root2",
4260 " v dir3",
4261 " file5.txt <== selected",
4262 " file6.txt",
4263 ],
4264 "Should select next file in the last worktree after deletion"
4265 );
4266
4267 // Test Case 2: Delete directories from different worktrees
4268 select_path_with_mark(&panel, "root1/dir1", cx);
4269 select_path_with_mark(&panel, "root2/dir3", cx);
4270
4271 assert_eq!(
4272 visible_entries_as_strings(&panel, 0..20, cx),
4273 &[
4274 "v root1",
4275 " v dir1 <== marked",
4276 " file2.txt",
4277 " v dir2",
4278 " file3.txt",
4279 "v root2",
4280 " v dir3 <== selected <== marked",
4281 " file5.txt",
4282 " file6.txt",
4283 ],
4284 "State with directories marked from different worktrees"
4285 );
4286
4287 submit_deletion(&panel, cx);
4288 assert_eq!(
4289 visible_entries_as_strings(&panel, 0..20, cx),
4290 &[
4291 "v root1",
4292 " v dir2",
4293 " file3.txt",
4294 "v root2",
4295 " file6.txt <== selected",
4296 ],
4297 "Should select remaining file in last worktree after directory deletion"
4298 );
4299
4300 // Test Case 4: Delete all remaining files except roots
4301 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4302 select_path_with_mark(&panel, "root2/file6.txt", cx);
4303
4304 assert_eq!(
4305 visible_entries_as_strings(&panel, 0..20, cx),
4306 &[
4307 "v root1",
4308 " v dir2",
4309 " file3.txt <== marked",
4310 "v root2",
4311 " file6.txt <== selected <== marked",
4312 ],
4313 "State with all remaining files marked"
4314 );
4315
4316 submit_deletion(&panel, cx);
4317 assert_eq!(
4318 visible_entries_as_strings(&panel, 0..20, cx),
4319 &["v root1", " v dir2", "v root2 <== selected"],
4320 "Second parent root should be selected after deleting"
4321 );
4322}
4323
4324#[gpui::test]
4325async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4326 init_test_with_editor(cx);
4327
4328 let fs = FakeFs::new(cx.executor().clone());
4329 fs.insert_tree(
4330 "/root",
4331 json!({
4332 "dir1": {
4333 "file1.txt": "",
4334 "file2.txt": "",
4335 "file3.txt": "",
4336 },
4337 "dir2": {
4338 "file4.txt": "",
4339 "file5.txt": "",
4340 },
4341 }),
4342 )
4343 .await;
4344
4345 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4346 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4347 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4348 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4349
4350 toggle_expand_dir(&panel, "root/dir1", cx);
4351 toggle_expand_dir(&panel, "root/dir2", cx);
4352
4353 cx.simulate_modifiers_change(gpui::Modifiers {
4354 control: true,
4355 ..Default::default()
4356 });
4357
4358 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4359 select_path(&panel, "root/dir1/file1.txt", cx);
4360
4361 assert_eq!(
4362 visible_entries_as_strings(&panel, 0..15, cx),
4363 &[
4364 "v root",
4365 " v dir1",
4366 " file1.txt <== selected",
4367 " file2.txt <== marked",
4368 " file3.txt",
4369 " v dir2",
4370 " file4.txt",
4371 " file5.txt",
4372 ],
4373 "Initial state with one marked entry and different selection"
4374 );
4375
4376 // Delete should operate on the selected entry (file1.txt)
4377 submit_deletion(&panel, cx);
4378 assert_eq!(
4379 visible_entries_as_strings(&panel, 0..15, cx),
4380 &[
4381 "v root",
4382 " v dir1",
4383 " file2.txt <== selected <== marked",
4384 " file3.txt",
4385 " v dir2",
4386 " file4.txt",
4387 " file5.txt",
4388 ],
4389 "Should delete selected file, not marked file"
4390 );
4391
4392 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
4393 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
4394 select_path(&panel, "root/dir2/file5.txt", cx);
4395
4396 assert_eq!(
4397 visible_entries_as_strings(&panel, 0..15, cx),
4398 &[
4399 "v root",
4400 " v dir1",
4401 " file2.txt <== marked",
4402 " file3.txt <== marked",
4403 " v dir2",
4404 " file4.txt <== marked",
4405 " file5.txt <== selected",
4406 ],
4407 "Initial state with multiple marked entries and different selection"
4408 );
4409
4410 // Delete should operate on all marked entries, ignoring the selection
4411 submit_deletion(&panel, cx);
4412 assert_eq!(
4413 visible_entries_as_strings(&panel, 0..15, cx),
4414 &[
4415 "v root",
4416 " v dir1",
4417 " v dir2",
4418 " file5.txt <== selected",
4419 ],
4420 "Should delete all marked files, leaving only the selected file"
4421 );
4422}
4423
4424#[gpui::test]
4425async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
4426 init_test_with_editor(cx);
4427
4428 let fs = FakeFs::new(cx.executor().clone());
4429 fs.insert_tree(
4430 "/root_b",
4431 json!({
4432 "dir1": {
4433 "file1.txt": "content 1",
4434 "file2.txt": "content 2",
4435 },
4436 }),
4437 )
4438 .await;
4439
4440 fs.insert_tree(
4441 "/root_c",
4442 json!({
4443 "dir2": {},
4444 }),
4445 )
4446 .await;
4447
4448 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
4449 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4450 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4451 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4452
4453 toggle_expand_dir(&panel, "root_b/dir1", cx);
4454 toggle_expand_dir(&panel, "root_c/dir2", cx);
4455
4456 cx.simulate_modifiers_change(gpui::Modifiers {
4457 control: true,
4458 ..Default::default()
4459 });
4460 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
4461 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
4462
4463 assert_eq!(
4464 visible_entries_as_strings(&panel, 0..20, cx),
4465 &[
4466 "v root_b",
4467 " v dir1",
4468 " file1.txt <== marked",
4469 " file2.txt <== selected <== marked",
4470 "v root_c",
4471 " v dir2",
4472 ],
4473 "Initial state with files marked in root_b"
4474 );
4475
4476 submit_deletion(&panel, cx);
4477 assert_eq!(
4478 visible_entries_as_strings(&panel, 0..20, cx),
4479 &[
4480 "v root_b",
4481 " v dir1 <== selected",
4482 "v root_c",
4483 " v dir2",
4484 ],
4485 "After deletion in root_b as it's last deletion, selection should be in root_b"
4486 );
4487
4488 select_path_with_mark(&panel, "root_c/dir2", cx);
4489
4490 submit_deletion(&panel, cx);
4491 assert_eq!(
4492 visible_entries_as_strings(&panel, 0..20, cx),
4493 &["v root_b", " v dir1", "v root_c <== selected",],
4494 "After deleting from root_c, it should remain in root_c"
4495 );
4496}
4497
4498fn toggle_expand_dir(
4499 panel: &Entity<ProjectPanel>,
4500 path: impl AsRef<Path>,
4501 cx: &mut VisualTestContext,
4502) {
4503 let path = path.as_ref();
4504 panel.update_in(cx, |panel, window, cx| {
4505 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4506 let worktree = worktree.read(cx);
4507 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4508 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4509 panel.toggle_expanded(entry_id, window, cx);
4510 return;
4511 }
4512 }
4513 panic!("no worktree for path {:?}", path);
4514 });
4515}
4516
4517#[gpui::test]
4518async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
4519 init_test_with_editor(cx);
4520
4521 let fs = FakeFs::new(cx.executor().clone());
4522 fs.insert_tree(
4523 path!("/root"),
4524 json!({
4525 ".gitignore": "**/ignored_dir\n**/ignored_nested",
4526 "dir1": {
4527 "empty1": {
4528 "empty2": {
4529 "empty3": {
4530 "file.txt": ""
4531 }
4532 }
4533 },
4534 "subdir1": {
4535 "file1.txt": "",
4536 "file2.txt": "",
4537 "ignored_nested": {
4538 "ignored_file.txt": ""
4539 }
4540 },
4541 "ignored_dir": {
4542 "subdir": {
4543 "deep_file.txt": ""
4544 }
4545 }
4546 }
4547 }),
4548 )
4549 .await;
4550
4551 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4552 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4553 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4554
4555 // Test 1: When auto-fold is enabled
4556 cx.update(|_, cx| {
4557 let settings = *ProjectPanelSettings::get_global(cx);
4558 ProjectPanelSettings::override_global(
4559 ProjectPanelSettings {
4560 auto_fold_dirs: true,
4561 ..settings
4562 },
4563 cx,
4564 );
4565 });
4566
4567 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4568
4569 assert_eq!(
4570 visible_entries_as_strings(&panel, 0..20, cx),
4571 &["v root", " > dir1", " .gitignore",],
4572 "Initial state should show collapsed root structure"
4573 );
4574
4575 toggle_expand_dir(&panel, "root/dir1", cx);
4576 assert_eq!(
4577 visible_entries_as_strings(&panel, 0..20, cx),
4578 &[
4579 separator!("v root"),
4580 separator!(" v dir1 <== selected"),
4581 separator!(" > empty1/empty2/empty3"),
4582 separator!(" > ignored_dir"),
4583 separator!(" > subdir1"),
4584 separator!(" .gitignore"),
4585 ],
4586 "Should show first level with auto-folded dirs and ignored dir visible"
4587 );
4588
4589 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4590 panel.update(cx, |panel, cx| {
4591 let project = panel.project.read(cx);
4592 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4593 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4594 panel.update_visible_entries(None, cx);
4595 });
4596 cx.run_until_parked();
4597
4598 assert_eq!(
4599 visible_entries_as_strings(&panel, 0..20, cx),
4600 &[
4601 separator!("v root"),
4602 separator!(" v dir1 <== selected"),
4603 separator!(" v empty1"),
4604 separator!(" v empty2"),
4605 separator!(" v empty3"),
4606 separator!(" file.txt"),
4607 separator!(" > ignored_dir"),
4608 separator!(" v subdir1"),
4609 separator!(" > ignored_nested"),
4610 separator!(" file1.txt"),
4611 separator!(" file2.txt"),
4612 separator!(" .gitignore"),
4613 ],
4614 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
4615 );
4616
4617 // Test 2: When auto-fold is disabled
4618 cx.update(|_, cx| {
4619 let settings = *ProjectPanelSettings::get_global(cx);
4620 ProjectPanelSettings::override_global(
4621 ProjectPanelSettings {
4622 auto_fold_dirs: false,
4623 ..settings
4624 },
4625 cx,
4626 );
4627 });
4628
4629 panel.update_in(cx, |panel, window, cx| {
4630 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
4631 });
4632
4633 toggle_expand_dir(&panel, "root/dir1", cx);
4634 assert_eq!(
4635 visible_entries_as_strings(&panel, 0..20, cx),
4636 &[
4637 separator!("v root"),
4638 separator!(" v dir1 <== selected"),
4639 separator!(" > empty1"),
4640 separator!(" > ignored_dir"),
4641 separator!(" > subdir1"),
4642 separator!(" .gitignore"),
4643 ],
4644 "With auto-fold disabled: should show all directories separately"
4645 );
4646
4647 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4648 panel.update(cx, |panel, cx| {
4649 let project = panel.project.read(cx);
4650 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4651 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4652 panel.update_visible_entries(None, cx);
4653 });
4654 cx.run_until_parked();
4655
4656 assert_eq!(
4657 visible_entries_as_strings(&panel, 0..20, cx),
4658 &[
4659 separator!("v root"),
4660 separator!(" v dir1 <== selected"),
4661 separator!(" v empty1"),
4662 separator!(" v empty2"),
4663 separator!(" v empty3"),
4664 separator!(" file.txt"),
4665 separator!(" > ignored_dir"),
4666 separator!(" v subdir1"),
4667 separator!(" > ignored_nested"),
4668 separator!(" file1.txt"),
4669 separator!(" file2.txt"),
4670 separator!(" .gitignore"),
4671 ],
4672 "After expand_all without auto-fold: should expand all dirs normally, \
4673 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
4674 );
4675
4676 // Test 3: When explicitly called on ignored directory
4677 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
4678 panel.update(cx, |panel, cx| {
4679 let project = panel.project.read(cx);
4680 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4681 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
4682 panel.update_visible_entries(None, cx);
4683 });
4684 cx.run_until_parked();
4685
4686 assert_eq!(
4687 visible_entries_as_strings(&panel, 0..20, cx),
4688 &[
4689 separator!("v root"),
4690 separator!(" v dir1 <== selected"),
4691 separator!(" v empty1"),
4692 separator!(" v empty2"),
4693 separator!(" v empty3"),
4694 separator!(" file.txt"),
4695 separator!(" v ignored_dir"),
4696 separator!(" v subdir"),
4697 separator!(" deep_file.txt"),
4698 separator!(" v subdir1"),
4699 separator!(" > ignored_nested"),
4700 separator!(" file1.txt"),
4701 separator!(" file2.txt"),
4702 separator!(" .gitignore"),
4703 ],
4704 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
4705 );
4706}
4707
4708#[gpui::test]
4709async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
4710 init_test(cx);
4711
4712 let fs = FakeFs::new(cx.executor().clone());
4713 fs.insert_tree(
4714 path!("/root"),
4715 json!({
4716 "dir1": {
4717 "subdir1": {
4718 "nested1": {
4719 "file1.txt": "",
4720 "file2.txt": ""
4721 },
4722 },
4723 "subdir2": {
4724 "file4.txt": ""
4725 }
4726 },
4727 "dir2": {
4728 "single_file": {
4729 "file5.txt": ""
4730 }
4731 }
4732 }),
4733 )
4734 .await;
4735
4736 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4737 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4738 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4739
4740 // Test 1: Basic collapsing
4741 {
4742 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4743
4744 toggle_expand_dir(&panel, "root/dir1", cx);
4745 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4746 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4747 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
4748
4749 assert_eq!(
4750 visible_entries_as_strings(&panel, 0..20, cx),
4751 &[
4752 separator!("v root"),
4753 separator!(" v dir1"),
4754 separator!(" v subdir1"),
4755 separator!(" v nested1"),
4756 separator!(" file1.txt"),
4757 separator!(" file2.txt"),
4758 separator!(" v subdir2 <== selected"),
4759 separator!(" file4.txt"),
4760 separator!(" > dir2"),
4761 ],
4762 "Initial state with everything expanded"
4763 );
4764
4765 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4766 panel.update(cx, |panel, cx| {
4767 let project = panel.project.read(cx);
4768 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4769 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4770 panel.update_visible_entries(None, cx);
4771 });
4772
4773 assert_eq!(
4774 visible_entries_as_strings(&panel, 0..20, cx),
4775 &["v root", " > dir1", " > dir2",],
4776 "All subdirs under dir1 should be collapsed"
4777 );
4778 }
4779
4780 // Test 2: With auto-fold enabled
4781 {
4782 cx.update(|_, cx| {
4783 let settings = *ProjectPanelSettings::get_global(cx);
4784 ProjectPanelSettings::override_global(
4785 ProjectPanelSettings {
4786 auto_fold_dirs: true,
4787 ..settings
4788 },
4789 cx,
4790 );
4791 });
4792
4793 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4794
4795 toggle_expand_dir(&panel, "root/dir1", cx);
4796 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4797 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4798
4799 assert_eq!(
4800 visible_entries_as_strings(&panel, 0..20, cx),
4801 &[
4802 separator!("v root"),
4803 separator!(" v dir1"),
4804 separator!(" v subdir1/nested1 <== selected"),
4805 separator!(" file1.txt"),
4806 separator!(" file2.txt"),
4807 separator!(" > subdir2"),
4808 separator!(" > dir2/single_file"),
4809 ],
4810 "Initial state with some dirs expanded"
4811 );
4812
4813 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4814 panel.update(cx, |panel, cx| {
4815 let project = panel.project.read(cx);
4816 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4817 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4818 });
4819
4820 toggle_expand_dir(&panel, "root/dir1", cx);
4821
4822 assert_eq!(
4823 visible_entries_as_strings(&panel, 0..20, cx),
4824 &[
4825 separator!("v root"),
4826 separator!(" v dir1 <== selected"),
4827 separator!(" > subdir1/nested1"),
4828 separator!(" > subdir2"),
4829 separator!(" > dir2/single_file"),
4830 ],
4831 "Subdirs should be collapsed and folded with auto-fold enabled"
4832 );
4833 }
4834
4835 // Test 3: With auto-fold disabled
4836 {
4837 cx.update(|_, cx| {
4838 let settings = *ProjectPanelSettings::get_global(cx);
4839 ProjectPanelSettings::override_global(
4840 ProjectPanelSettings {
4841 auto_fold_dirs: false,
4842 ..settings
4843 },
4844 cx,
4845 );
4846 });
4847
4848 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4849
4850 toggle_expand_dir(&panel, "root/dir1", cx);
4851 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4852 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4853
4854 assert_eq!(
4855 visible_entries_as_strings(&panel, 0..20, cx),
4856 &[
4857 separator!("v root"),
4858 separator!(" v dir1"),
4859 separator!(" v subdir1"),
4860 separator!(" v nested1 <== selected"),
4861 separator!(" file1.txt"),
4862 separator!(" file2.txt"),
4863 separator!(" > subdir2"),
4864 separator!(" > dir2"),
4865 ],
4866 "Initial state with some dirs expanded and auto-fold disabled"
4867 );
4868
4869 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4870 panel.update(cx, |panel, cx| {
4871 let project = panel.project.read(cx);
4872 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4873 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4874 });
4875
4876 toggle_expand_dir(&panel, "root/dir1", cx);
4877
4878 assert_eq!(
4879 visible_entries_as_strings(&panel, 0..20, cx),
4880 &[
4881 separator!("v root"),
4882 separator!(" v dir1 <== selected"),
4883 separator!(" > subdir1"),
4884 separator!(" > subdir2"),
4885 separator!(" > dir2"),
4886 ],
4887 "Subdirs should be collapsed but not folded with auto-fold disabled"
4888 );
4889 }
4890}
4891
4892fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4893 let path = path.as_ref();
4894 panel.update(cx, |panel, cx| {
4895 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4896 let worktree = worktree.read(cx);
4897 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4898 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4899 panel.selection = Some(crate::SelectedEntry {
4900 worktree_id: worktree.id(),
4901 entry_id,
4902 });
4903 return;
4904 }
4905 }
4906 panic!("no worktree for path {:?}", path);
4907 });
4908}
4909
4910fn select_path_with_mark(
4911 panel: &Entity<ProjectPanel>,
4912 path: impl AsRef<Path>,
4913 cx: &mut VisualTestContext,
4914) {
4915 let path = path.as_ref();
4916 panel.update(cx, |panel, cx| {
4917 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4918 let worktree = worktree.read(cx);
4919 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4920 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4921 let entry = crate::SelectedEntry {
4922 worktree_id: worktree.id(),
4923 entry_id,
4924 };
4925 if !panel.marked_entries.contains(&entry) {
4926 panel.marked_entries.insert(entry);
4927 }
4928 panel.selection = Some(entry);
4929 return;
4930 }
4931 }
4932 panic!("no worktree for path {:?}", path);
4933 });
4934}
4935
4936fn find_project_entry(
4937 panel: &Entity<ProjectPanel>,
4938 path: impl AsRef<Path>,
4939 cx: &mut VisualTestContext,
4940) -> Option<ProjectEntryId> {
4941 let path = path.as_ref();
4942 panel.update(cx, |panel, cx| {
4943 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4944 let worktree = worktree.read(cx);
4945 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4946 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4947 }
4948 }
4949 panic!("no worktree for path {path:?}");
4950 })
4951}
4952
4953fn visible_entries_as_strings(
4954 panel: &Entity<ProjectPanel>,
4955 range: Range<usize>,
4956 cx: &mut VisualTestContext,
4957) -> Vec<String> {
4958 let mut result = Vec::new();
4959 let mut project_entries = HashSet::default();
4960 let mut has_editor = false;
4961
4962 panel.update_in(cx, |panel, window, cx| {
4963 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
4964 if details.is_editing {
4965 assert!(!has_editor, "duplicate editor entry");
4966 has_editor = true;
4967 } else {
4968 assert!(
4969 project_entries.insert(project_entry),
4970 "duplicate project entry {:?} {:?}",
4971 project_entry,
4972 details
4973 );
4974 }
4975
4976 let indent = " ".repeat(details.depth);
4977 let icon = if details.kind.is_dir() {
4978 if details.is_expanded { "v " } else { "> " }
4979 } else {
4980 " "
4981 };
4982 let name = if details.is_editing {
4983 format!("[EDITOR: '{}']", details.filename)
4984 } else if details.is_processing {
4985 format!("[PROCESSING: '{}']", details.filename)
4986 } else {
4987 details.filename.clone()
4988 };
4989 let selected = if details.is_selected {
4990 " <== selected"
4991 } else {
4992 ""
4993 };
4994 let marked = if details.is_marked {
4995 " <== marked"
4996 } else {
4997 ""
4998 };
4999
5000 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
5001 });
5002 });
5003
5004 result
5005}
5006
5007fn init_test(cx: &mut TestAppContext) {
5008 cx.update(|cx| {
5009 let settings_store = SettingsStore::test(cx);
5010 cx.set_global(settings_store);
5011 init_settings(cx);
5012 theme::init(theme::LoadThemes::JustBase, cx);
5013 language::init(cx);
5014 editor::init_settings(cx);
5015 crate::init(cx);
5016 workspace::init_settings(cx);
5017 client::init_settings(cx);
5018 Project::init_settings(cx);
5019
5020 cx.update_global::<SettingsStore, _>(|store, cx| {
5021 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5022 project_panel_settings.auto_fold_dirs = Some(false);
5023 });
5024 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5025 worktree_settings.file_scan_exclusions = Some(Vec::new());
5026 });
5027 });
5028 });
5029}
5030
5031fn init_test_with_editor(cx: &mut TestAppContext) {
5032 cx.update(|cx| {
5033 let app_state = AppState::test(cx);
5034 theme::init(theme::LoadThemes::JustBase, cx);
5035 init_settings(cx);
5036 language::init(cx);
5037 editor::init(cx);
5038 crate::init(cx);
5039 workspace::init(app_state.clone(), cx);
5040 Project::init_settings(cx);
5041
5042 cx.update_global::<SettingsStore, _>(|store, cx| {
5043 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5044 project_panel_settings.auto_fold_dirs = Some(false);
5045 });
5046 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5047 worktree_settings.file_scan_exclusions = Some(Vec::new());
5048 });
5049 });
5050 });
5051}
5052
5053fn ensure_single_file_is_opened(
5054 window: &WindowHandle<Workspace>,
5055 expected_path: &str,
5056 cx: &mut TestAppContext,
5057) {
5058 window
5059 .update(cx, |workspace, _, cx| {
5060 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
5061 assert_eq!(worktrees.len(), 1);
5062 let worktree_id = worktrees[0].read(cx).id();
5063
5064 let open_project_paths = workspace
5065 .panes()
5066 .iter()
5067 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5068 .collect::<Vec<_>>();
5069 assert_eq!(
5070 open_project_paths,
5071 vec![ProjectPath {
5072 worktree_id,
5073 path: Arc::from(Path::new(expected_path))
5074 }],
5075 "Should have opened file, selected in project panel"
5076 );
5077 })
5078 .unwrap();
5079}
5080
5081fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
5082 assert!(
5083 !cx.has_pending_prompt(),
5084 "Should have no prompts before the deletion"
5085 );
5086 panel.update_in(cx, |panel, window, cx| {
5087 panel.delete(&Delete { skip_prompt: false }, window, cx)
5088 });
5089 assert!(
5090 cx.has_pending_prompt(),
5091 "Should have a prompt after the deletion"
5092 );
5093 cx.simulate_prompt_answer("Delete");
5094 assert!(
5095 !cx.has_pending_prompt(),
5096 "Should have no prompts after prompt was replied to"
5097 );
5098 cx.executor().run_until_parked();
5099}
5100
5101fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
5102 assert!(
5103 !cx.has_pending_prompt(),
5104 "Should have no prompts before the deletion"
5105 );
5106 panel.update_in(cx, |panel, window, cx| {
5107 panel.delete(&Delete { skip_prompt: true }, window, cx)
5108 });
5109 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5110 cx.executor().run_until_parked();
5111}
5112
5113fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
5114 assert!(
5115 !cx.has_pending_prompt(),
5116 "Should have no prompts after deletion operation closes the file"
5117 );
5118 workspace
5119 .read_with(cx, |workspace, cx| {
5120 let open_project_paths = workspace
5121 .panes()
5122 .iter()
5123 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5124 .collect::<Vec<_>>();
5125 assert!(
5126 open_project_paths.is_empty(),
5127 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5128 );
5129 })
5130 .unwrap();
5131}
5132
5133struct TestProjectItemView {
5134 focus_handle: FocusHandle,
5135 path: ProjectPath,
5136}
5137
5138struct TestProjectItem {
5139 path: ProjectPath,
5140}
5141
5142impl project::ProjectItem for TestProjectItem {
5143 fn try_open(
5144 _project: &Entity<Project>,
5145 path: &ProjectPath,
5146 cx: &mut App,
5147 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
5148 let path = path.clone();
5149 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
5150 }
5151
5152 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
5153 None
5154 }
5155
5156 fn project_path(&self, _: &App) -> Option<ProjectPath> {
5157 Some(self.path.clone())
5158 }
5159
5160 fn is_dirty(&self) -> bool {
5161 false
5162 }
5163}
5164
5165impl ProjectItem for TestProjectItemView {
5166 type Item = TestProjectItem;
5167
5168 fn for_project_item(
5169 _: Entity<Project>,
5170 _: &Pane,
5171 project_item: Entity<Self::Item>,
5172 _: &mut Window,
5173 cx: &mut Context<Self>,
5174 ) -> Self
5175 where
5176 Self: Sized,
5177 {
5178 Self {
5179 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5180 focus_handle: cx.focus_handle(),
5181 }
5182 }
5183}
5184
5185impl Item for TestProjectItemView {
5186 type Event = ();
5187}
5188
5189impl EventEmitter<()> for TestProjectItemView {}
5190
5191impl Focusable for TestProjectItemView {
5192 fn focus_handle(&self, _: &App) -> FocusHandle {
5193 self.focus_handle.clone()
5194 }
5195}
5196
5197impl Render for TestProjectItemView {
5198 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5199 Empty
5200 }
5201}