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