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