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_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
3740 init_test_with_editor(cx);
3741
3742 let fs = FakeFs::new(cx.executor().clone());
3743 fs.insert_tree(
3744 "/root",
3745 json!({
3746 "dir1": {
3747 "subdir1": {
3748 "a.txt": "",
3749 "b.txt": ""
3750 },
3751 "file1.txt": "",
3752 },
3753 "dir2": {
3754 "subdir2": {
3755 "c.txt": "",
3756 "d.txt": ""
3757 },
3758 "file2.txt": "",
3759 },
3760 "file3.txt": "",
3761 }),
3762 )
3763 .await;
3764
3765 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3766 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3767 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3768 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3769
3770 toggle_expand_dir(&panel, "root/dir1", cx);
3771 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3772 toggle_expand_dir(&panel, "root/dir2", cx);
3773 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
3774
3775 // Test Case 1: Select and delete nested directory with parent
3776 cx.simulate_modifiers_change(gpui::Modifiers {
3777 control: true,
3778 ..Default::default()
3779 });
3780 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
3781 select_path_with_mark(&panel, "root/dir1", cx);
3782
3783 assert_eq!(
3784 visible_entries_as_strings(&panel, 0..15, cx),
3785 &[
3786 "v root",
3787 " v dir1 <== selected <== marked",
3788 " v subdir1 <== marked",
3789 " a.txt",
3790 " b.txt",
3791 " file1.txt",
3792 " v dir2",
3793 " v subdir2",
3794 " c.txt",
3795 " d.txt",
3796 " file2.txt",
3797 " file3.txt",
3798 ],
3799 "Initial state before deleting nested directory with parent"
3800 );
3801
3802 submit_deletion(&panel, cx);
3803 assert_eq!(
3804 visible_entries_as_strings(&panel, 0..15, cx),
3805 &[
3806 "v root",
3807 " v dir2 <== selected",
3808 " v subdir2",
3809 " c.txt",
3810 " d.txt",
3811 " file2.txt",
3812 " file3.txt",
3813 ],
3814 "Should select next directory after deleting directory with parent"
3815 );
3816
3817 // Test Case 2: Select mixed files and directories across levels
3818 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
3819 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
3820 select_path_with_mark(&panel, "root/file3.txt", cx);
3821
3822 assert_eq!(
3823 visible_entries_as_strings(&panel, 0..15, cx),
3824 &[
3825 "v root",
3826 " v dir2",
3827 " v subdir2",
3828 " c.txt <== marked",
3829 " d.txt",
3830 " file2.txt <== marked",
3831 " file3.txt <== selected <== marked",
3832 ],
3833 "Initial state before deleting"
3834 );
3835
3836 submit_deletion(&panel, cx);
3837 assert_eq!(
3838 visible_entries_as_strings(&panel, 0..15, cx),
3839 &[
3840 "v root",
3841 " v dir2 <== selected",
3842 " v subdir2",
3843 " d.txt",
3844 ],
3845 "Should select sibling directory"
3846 );
3847}
3848
3849#[gpui::test]
3850async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
3851 init_test_with_editor(cx);
3852
3853 let fs = FakeFs::new(cx.executor().clone());
3854 fs.insert_tree(
3855 "/root",
3856 json!({
3857 "dir1": {
3858 "subdir1": {
3859 "a.txt": "",
3860 "b.txt": ""
3861 },
3862 "file1.txt": "",
3863 },
3864 "dir2": {
3865 "subdir2": {
3866 "c.txt": "",
3867 "d.txt": ""
3868 },
3869 "file2.txt": "",
3870 },
3871 "file3.txt": "",
3872 "file4.txt": "",
3873 }),
3874 )
3875 .await;
3876
3877 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3878 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3879 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3880 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3881
3882 toggle_expand_dir(&panel, "root/dir1", cx);
3883 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3884 toggle_expand_dir(&panel, "root/dir2", cx);
3885 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
3886
3887 // Test Case 1: Select all root files and directories
3888 cx.simulate_modifiers_change(gpui::Modifiers {
3889 control: true,
3890 ..Default::default()
3891 });
3892 select_path_with_mark(&panel, "root/dir1", cx);
3893 select_path_with_mark(&panel, "root/dir2", cx);
3894 select_path_with_mark(&panel, "root/file3.txt", cx);
3895 select_path_with_mark(&panel, "root/file4.txt", cx);
3896 assert_eq!(
3897 visible_entries_as_strings(&panel, 0..20, cx),
3898 &[
3899 "v root",
3900 " v dir1 <== marked",
3901 " v subdir1",
3902 " a.txt",
3903 " b.txt",
3904 " file1.txt",
3905 " v dir2 <== marked",
3906 " v subdir2",
3907 " c.txt",
3908 " d.txt",
3909 " file2.txt",
3910 " file3.txt <== marked",
3911 " file4.txt <== selected <== marked",
3912 ],
3913 "State before deleting all contents"
3914 );
3915
3916 submit_deletion(&panel, cx);
3917 assert_eq!(
3918 visible_entries_as_strings(&panel, 0..20, cx),
3919 &["v root <== selected"],
3920 "Only empty root directory should remain after deleting all contents"
3921 );
3922}
3923
3924#[gpui::test]
3925async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
3926 init_test_with_editor(cx);
3927
3928 let fs = FakeFs::new(cx.executor().clone());
3929 fs.insert_tree(
3930 "/root",
3931 json!({
3932 "dir1": {
3933 "subdir1": {
3934 "file_a.txt": "content a",
3935 "file_b.txt": "content b",
3936 },
3937 "subdir2": {
3938 "file_c.txt": "content c",
3939 },
3940 "file1.txt": "content 1",
3941 },
3942 "dir2": {
3943 "file2.txt": "content 2",
3944 },
3945 }),
3946 )
3947 .await;
3948
3949 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3950 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3951 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3952 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3953
3954 toggle_expand_dir(&panel, "root/dir1", cx);
3955 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
3956 toggle_expand_dir(&panel, "root/dir2", cx);
3957 cx.simulate_modifiers_change(gpui::Modifiers {
3958 control: true,
3959 ..Default::default()
3960 });
3961
3962 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
3963 select_path_with_mark(&panel, "root/dir1", cx);
3964 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
3965 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
3966
3967 assert_eq!(
3968 visible_entries_as_strings(&panel, 0..20, cx),
3969 &[
3970 "v root",
3971 " v dir1 <== marked",
3972 " v subdir1 <== marked",
3973 " file_a.txt <== selected <== marked",
3974 " file_b.txt",
3975 " > subdir2",
3976 " file1.txt",
3977 " v dir2",
3978 " file2.txt",
3979 ],
3980 "State with parent dir, subdir, and file selected"
3981 );
3982 submit_deletion(&panel, cx);
3983 assert_eq!(
3984 visible_entries_as_strings(&panel, 0..20, cx),
3985 &["v root", " v dir2 <== selected", " file2.txt",],
3986 "Only dir2 should remain after deletion"
3987 );
3988}
3989
3990#[gpui::test]
3991async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
3992 init_test_with_editor(cx);
3993
3994 let fs = FakeFs::new(cx.executor().clone());
3995 // First worktree
3996 fs.insert_tree(
3997 "/root1",
3998 json!({
3999 "dir1": {
4000 "file1.txt": "content 1",
4001 "file2.txt": "content 2",
4002 },
4003 "dir2": {
4004 "file3.txt": "content 3",
4005 },
4006 }),
4007 )
4008 .await;
4009
4010 // Second worktree
4011 fs.insert_tree(
4012 "/root2",
4013 json!({
4014 "dir3": {
4015 "file4.txt": "content 4",
4016 "file5.txt": "content 5",
4017 },
4018 "file6.txt": "content 6",
4019 }),
4020 )
4021 .await;
4022
4023 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4024 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4025 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4026 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4027
4028 // Expand all directories for testing
4029 toggle_expand_dir(&panel, "root1/dir1", cx);
4030 toggle_expand_dir(&panel, "root1/dir2", cx);
4031 toggle_expand_dir(&panel, "root2/dir3", cx);
4032
4033 // Test Case 1: Delete files across different worktrees
4034 cx.simulate_modifiers_change(gpui::Modifiers {
4035 control: true,
4036 ..Default::default()
4037 });
4038 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4039 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4040
4041 assert_eq!(
4042 visible_entries_as_strings(&panel, 0..20, cx),
4043 &[
4044 "v root1",
4045 " v dir1",
4046 " file1.txt <== marked",
4047 " file2.txt",
4048 " v dir2",
4049 " file3.txt",
4050 "v root2",
4051 " v dir3",
4052 " file4.txt <== selected <== marked",
4053 " file5.txt",
4054 " file6.txt",
4055 ],
4056 "Initial state with files selected from different worktrees"
4057 );
4058
4059 submit_deletion(&panel, cx);
4060 assert_eq!(
4061 visible_entries_as_strings(&panel, 0..20, cx),
4062 &[
4063 "v root1",
4064 " v dir1",
4065 " file2.txt",
4066 " v dir2",
4067 " file3.txt",
4068 "v root2",
4069 " v dir3",
4070 " file5.txt <== selected",
4071 " file6.txt",
4072 ],
4073 "Should select next file in the last worktree after deletion"
4074 );
4075
4076 // Test Case 2: Delete directories from different worktrees
4077 select_path_with_mark(&panel, "root1/dir1", cx);
4078 select_path_with_mark(&panel, "root2/dir3", cx);
4079
4080 assert_eq!(
4081 visible_entries_as_strings(&panel, 0..20, cx),
4082 &[
4083 "v root1",
4084 " v dir1 <== marked",
4085 " file2.txt",
4086 " v dir2",
4087 " file3.txt",
4088 "v root2",
4089 " v dir3 <== selected <== marked",
4090 " file5.txt",
4091 " file6.txt",
4092 ],
4093 "State with directories marked from different worktrees"
4094 );
4095
4096 submit_deletion(&panel, cx);
4097 assert_eq!(
4098 visible_entries_as_strings(&panel, 0..20, cx),
4099 &[
4100 "v root1",
4101 " v dir2",
4102 " file3.txt",
4103 "v root2",
4104 " file6.txt <== selected",
4105 ],
4106 "Should select remaining file in last worktree after directory deletion"
4107 );
4108
4109 // Test Case 4: Delete all remaining files except roots
4110 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4111 select_path_with_mark(&panel, "root2/file6.txt", cx);
4112
4113 assert_eq!(
4114 visible_entries_as_strings(&panel, 0..20, cx),
4115 &[
4116 "v root1",
4117 " v dir2",
4118 " file3.txt <== marked",
4119 "v root2",
4120 " file6.txt <== selected <== marked",
4121 ],
4122 "State with all remaining files marked"
4123 );
4124
4125 submit_deletion(&panel, cx);
4126 assert_eq!(
4127 visible_entries_as_strings(&panel, 0..20, cx),
4128 &["v root1", " v dir2", "v root2 <== selected"],
4129 "Second parent root should be selected after deleting"
4130 );
4131}
4132
4133#[gpui::test]
4134async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4135 init_test_with_editor(cx);
4136
4137 let fs = FakeFs::new(cx.executor().clone());
4138 fs.insert_tree(
4139 "/root",
4140 json!({
4141 "dir1": {
4142 "file1.txt": "",
4143 "file2.txt": "",
4144 "file3.txt": "",
4145 },
4146 "dir2": {
4147 "file4.txt": "",
4148 "file5.txt": "",
4149 },
4150 }),
4151 )
4152 .await;
4153
4154 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4155 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4156 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4157 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4158
4159 toggle_expand_dir(&panel, "root/dir1", cx);
4160 toggle_expand_dir(&panel, "root/dir2", cx);
4161
4162 cx.simulate_modifiers_change(gpui::Modifiers {
4163 control: true,
4164 ..Default::default()
4165 });
4166
4167 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4168 select_path(&panel, "root/dir1/file1.txt", cx);
4169
4170 assert_eq!(
4171 visible_entries_as_strings(&panel, 0..15, cx),
4172 &[
4173 "v root",
4174 " v dir1",
4175 " file1.txt <== selected",
4176 " file2.txt <== marked",
4177 " file3.txt",
4178 " v dir2",
4179 " file4.txt",
4180 " file5.txt",
4181 ],
4182 "Initial state with one marked entry and different selection"
4183 );
4184
4185 // Delete should operate on the selected entry (file1.txt)
4186 submit_deletion(&panel, cx);
4187 assert_eq!(
4188 visible_entries_as_strings(&panel, 0..15, cx),
4189 &[
4190 "v root",
4191 " v dir1",
4192 " file2.txt <== selected <== marked",
4193 " file3.txt",
4194 " v dir2",
4195 " file4.txt",
4196 " file5.txt",
4197 ],
4198 "Should delete selected file, not marked file"
4199 );
4200
4201 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
4202 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
4203 select_path(&panel, "root/dir2/file5.txt", cx);
4204
4205 assert_eq!(
4206 visible_entries_as_strings(&panel, 0..15, cx),
4207 &[
4208 "v root",
4209 " v dir1",
4210 " file2.txt <== marked",
4211 " file3.txt <== marked",
4212 " v dir2",
4213 " file4.txt <== marked",
4214 " file5.txt <== selected",
4215 ],
4216 "Initial state with multiple marked entries and different selection"
4217 );
4218
4219 // Delete should operate on all marked entries, ignoring the selection
4220 submit_deletion(&panel, cx);
4221 assert_eq!(
4222 visible_entries_as_strings(&panel, 0..15, cx),
4223 &[
4224 "v root",
4225 " v dir1",
4226 " v dir2",
4227 " file5.txt <== selected",
4228 ],
4229 "Should delete all marked files, leaving only the selected file"
4230 );
4231}
4232
4233#[gpui::test]
4234async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
4235 init_test_with_editor(cx);
4236
4237 let fs = FakeFs::new(cx.executor().clone());
4238 fs.insert_tree(
4239 "/root_b",
4240 json!({
4241 "dir1": {
4242 "file1.txt": "content 1",
4243 "file2.txt": "content 2",
4244 },
4245 }),
4246 )
4247 .await;
4248
4249 fs.insert_tree(
4250 "/root_c",
4251 json!({
4252 "dir2": {},
4253 }),
4254 )
4255 .await;
4256
4257 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
4258 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4259 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4260 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4261
4262 toggle_expand_dir(&panel, "root_b/dir1", cx);
4263 toggle_expand_dir(&panel, "root_c/dir2", cx);
4264
4265 cx.simulate_modifiers_change(gpui::Modifiers {
4266 control: true,
4267 ..Default::default()
4268 });
4269 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
4270 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
4271
4272 assert_eq!(
4273 visible_entries_as_strings(&panel, 0..20, cx),
4274 &[
4275 "v root_b",
4276 " v dir1",
4277 " file1.txt <== marked",
4278 " file2.txt <== selected <== marked",
4279 "v root_c",
4280 " v dir2",
4281 ],
4282 "Initial state with files marked in root_b"
4283 );
4284
4285 submit_deletion(&panel, cx);
4286 assert_eq!(
4287 visible_entries_as_strings(&panel, 0..20, cx),
4288 &[
4289 "v root_b",
4290 " v dir1 <== selected",
4291 "v root_c",
4292 " v dir2",
4293 ],
4294 "After deletion in root_b as it's last deletion, selection should be in root_b"
4295 );
4296
4297 select_path_with_mark(&panel, "root_c/dir2", cx);
4298
4299 submit_deletion(&panel, cx);
4300 assert_eq!(
4301 visible_entries_as_strings(&panel, 0..20, cx),
4302 &["v root_b", " v dir1", "v root_c <== selected",],
4303 "After deleting from root_c, it should remain in root_c"
4304 );
4305}
4306
4307fn toggle_expand_dir(
4308 panel: &Entity<ProjectPanel>,
4309 path: impl AsRef<Path>,
4310 cx: &mut VisualTestContext,
4311) {
4312 let path = path.as_ref();
4313 panel.update_in(cx, |panel, window, cx| {
4314 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4315 let worktree = worktree.read(cx);
4316 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4317 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4318 panel.toggle_expanded(entry_id, window, cx);
4319 return;
4320 }
4321 }
4322 panic!("no worktree for path {:?}", path);
4323 });
4324}
4325
4326#[gpui::test]
4327async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
4328 init_test_with_editor(cx);
4329
4330 let fs = FakeFs::new(cx.executor().clone());
4331 fs.insert_tree(
4332 path!("/root"),
4333 json!({
4334 ".gitignore": "**/ignored_dir\n**/ignored_nested",
4335 "dir1": {
4336 "empty1": {
4337 "empty2": {
4338 "empty3": {
4339 "file.txt": ""
4340 }
4341 }
4342 },
4343 "subdir1": {
4344 "file1.txt": "",
4345 "file2.txt": "",
4346 "ignored_nested": {
4347 "ignored_file.txt": ""
4348 }
4349 },
4350 "ignored_dir": {
4351 "subdir": {
4352 "deep_file.txt": ""
4353 }
4354 }
4355 }
4356 }),
4357 )
4358 .await;
4359
4360 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4361 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4362 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4363
4364 // Test 1: When auto-fold is enabled
4365 cx.update(|_, cx| {
4366 let settings = *ProjectPanelSettings::get_global(cx);
4367 ProjectPanelSettings::override_global(
4368 ProjectPanelSettings {
4369 auto_fold_dirs: true,
4370 ..settings
4371 },
4372 cx,
4373 );
4374 });
4375
4376 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4377
4378 assert_eq!(
4379 visible_entries_as_strings(&panel, 0..20, cx),
4380 &["v root", " > dir1", " .gitignore",],
4381 "Initial state should show collapsed root structure"
4382 );
4383
4384 toggle_expand_dir(&panel, "root/dir1", cx);
4385 assert_eq!(
4386 visible_entries_as_strings(&panel, 0..20, cx),
4387 &[
4388 separator!("v root"),
4389 separator!(" v dir1 <== selected"),
4390 separator!(" > empty1/empty2/empty3"),
4391 separator!(" > ignored_dir"),
4392 separator!(" > subdir1"),
4393 separator!(" .gitignore"),
4394 ],
4395 "Should show first level with auto-folded dirs and ignored dir visible"
4396 );
4397
4398 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4399 panel.update(cx, |panel, cx| {
4400 let project = panel.project.read(cx);
4401 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4402 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4403 panel.update_visible_entries(None, cx);
4404 });
4405 cx.run_until_parked();
4406
4407 assert_eq!(
4408 visible_entries_as_strings(&panel, 0..20, cx),
4409 &[
4410 separator!("v root"),
4411 separator!(" v dir1 <== selected"),
4412 separator!(" v empty1"),
4413 separator!(" v empty2"),
4414 separator!(" v empty3"),
4415 separator!(" file.txt"),
4416 separator!(" > ignored_dir"),
4417 separator!(" v subdir1"),
4418 separator!(" > ignored_nested"),
4419 separator!(" file1.txt"),
4420 separator!(" file2.txt"),
4421 separator!(" .gitignore"),
4422 ],
4423 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
4424 );
4425
4426 // Test 2: When auto-fold is disabled
4427 cx.update(|_, cx| {
4428 let settings = *ProjectPanelSettings::get_global(cx);
4429 ProjectPanelSettings::override_global(
4430 ProjectPanelSettings {
4431 auto_fold_dirs: false,
4432 ..settings
4433 },
4434 cx,
4435 );
4436 });
4437
4438 panel.update_in(cx, |panel, window, cx| {
4439 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
4440 });
4441
4442 toggle_expand_dir(&panel, "root/dir1", cx);
4443 assert_eq!(
4444 visible_entries_as_strings(&panel, 0..20, cx),
4445 &[
4446 separator!("v root"),
4447 separator!(" v dir1 <== selected"),
4448 separator!(" > empty1"),
4449 separator!(" > ignored_dir"),
4450 separator!(" > subdir1"),
4451 separator!(" .gitignore"),
4452 ],
4453 "With auto-fold disabled: should show all directories separately"
4454 );
4455
4456 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4457 panel.update(cx, |panel, cx| {
4458 let project = panel.project.read(cx);
4459 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4460 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4461 panel.update_visible_entries(None, cx);
4462 });
4463 cx.run_until_parked();
4464
4465 assert_eq!(
4466 visible_entries_as_strings(&panel, 0..20, cx),
4467 &[
4468 separator!("v root"),
4469 separator!(" v dir1 <== selected"),
4470 separator!(" v empty1"),
4471 separator!(" v empty2"),
4472 separator!(" v empty3"),
4473 separator!(" file.txt"),
4474 separator!(" > ignored_dir"),
4475 separator!(" v subdir1"),
4476 separator!(" > ignored_nested"),
4477 separator!(" file1.txt"),
4478 separator!(" file2.txt"),
4479 separator!(" .gitignore"),
4480 ],
4481 "After expand_all without auto-fold: should expand all dirs normally, \
4482 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
4483 );
4484
4485 // Test 3: When explicitly called on ignored directory
4486 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
4487 panel.update(cx, |panel, cx| {
4488 let project = panel.project.read(cx);
4489 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4490 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
4491 panel.update_visible_entries(None, cx);
4492 });
4493 cx.run_until_parked();
4494
4495 assert_eq!(
4496 visible_entries_as_strings(&panel, 0..20, cx),
4497 &[
4498 separator!("v root"),
4499 separator!(" v dir1 <== selected"),
4500 separator!(" v empty1"),
4501 separator!(" v empty2"),
4502 separator!(" v empty3"),
4503 separator!(" file.txt"),
4504 separator!(" v ignored_dir"),
4505 separator!(" v subdir"),
4506 separator!(" deep_file.txt"),
4507 separator!(" v subdir1"),
4508 separator!(" > ignored_nested"),
4509 separator!(" file1.txt"),
4510 separator!(" file2.txt"),
4511 separator!(" .gitignore"),
4512 ],
4513 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
4514 );
4515}
4516
4517#[gpui::test]
4518async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
4519 init_test(cx);
4520
4521 let fs = FakeFs::new(cx.executor().clone());
4522 fs.insert_tree(
4523 path!("/root"),
4524 json!({
4525 "dir1": {
4526 "subdir1": {
4527 "nested1": {
4528 "file1.txt": "",
4529 "file2.txt": ""
4530 },
4531 },
4532 "subdir2": {
4533 "file4.txt": ""
4534 }
4535 },
4536 "dir2": {
4537 "single_file": {
4538 "file5.txt": ""
4539 }
4540 }
4541 }),
4542 )
4543 .await;
4544
4545 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4546 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4547 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4548
4549 // Test 1: Basic collapsing
4550 {
4551 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4552
4553 toggle_expand_dir(&panel, "root/dir1", cx);
4554 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4555 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4556 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
4557
4558 assert_eq!(
4559 visible_entries_as_strings(&panel, 0..20, cx),
4560 &[
4561 separator!("v root"),
4562 separator!(" v dir1"),
4563 separator!(" v subdir1"),
4564 separator!(" v nested1"),
4565 separator!(" file1.txt"),
4566 separator!(" file2.txt"),
4567 separator!(" v subdir2 <== selected"),
4568 separator!(" file4.txt"),
4569 separator!(" > dir2"),
4570 ],
4571 "Initial state with everything expanded"
4572 );
4573
4574 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4575 panel.update(cx, |panel, cx| {
4576 let project = panel.project.read(cx);
4577 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4578 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4579 panel.update_visible_entries(None, cx);
4580 });
4581
4582 assert_eq!(
4583 visible_entries_as_strings(&panel, 0..20, cx),
4584 &["v root", " > dir1", " > dir2",],
4585 "All subdirs under dir1 should be collapsed"
4586 );
4587 }
4588
4589 // Test 2: With auto-fold enabled
4590 {
4591 cx.update(|_, cx| {
4592 let settings = *ProjectPanelSettings::get_global(cx);
4593 ProjectPanelSettings::override_global(
4594 ProjectPanelSettings {
4595 auto_fold_dirs: true,
4596 ..settings
4597 },
4598 cx,
4599 );
4600 });
4601
4602 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4603
4604 toggle_expand_dir(&panel, "root/dir1", cx);
4605 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4606 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4607
4608 assert_eq!(
4609 visible_entries_as_strings(&panel, 0..20, cx),
4610 &[
4611 separator!("v root"),
4612 separator!(" v dir1"),
4613 separator!(" v subdir1/nested1 <== selected"),
4614 separator!(" file1.txt"),
4615 separator!(" file2.txt"),
4616 separator!(" > subdir2"),
4617 separator!(" > dir2/single_file"),
4618 ],
4619 "Initial state with some dirs expanded"
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.collapse_all_for_entry(worktree.id(), entry_id, cx);
4627 });
4628
4629 toggle_expand_dir(&panel, "root/dir1", cx);
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!(" > subdir1/nested1"),
4637 separator!(" > subdir2"),
4638 separator!(" > dir2/single_file"),
4639 ],
4640 "Subdirs should be collapsed and folded with auto-fold enabled"
4641 );
4642 }
4643
4644 // Test 3: With auto-fold disabled
4645 {
4646 cx.update(|_, cx| {
4647 let settings = *ProjectPanelSettings::get_global(cx);
4648 ProjectPanelSettings::override_global(
4649 ProjectPanelSettings {
4650 auto_fold_dirs: false,
4651 ..settings
4652 },
4653 cx,
4654 );
4655 });
4656
4657 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4658
4659 toggle_expand_dir(&panel, "root/dir1", cx);
4660 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4661 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
4662
4663 assert_eq!(
4664 visible_entries_as_strings(&panel, 0..20, cx),
4665 &[
4666 separator!("v root"),
4667 separator!(" v dir1"),
4668 separator!(" v subdir1"),
4669 separator!(" v nested1 <== selected"),
4670 separator!(" file1.txt"),
4671 separator!(" file2.txt"),
4672 separator!(" > subdir2"),
4673 separator!(" > dir2"),
4674 ],
4675 "Initial state with some dirs expanded and auto-fold disabled"
4676 );
4677
4678 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4679 panel.update(cx, |panel, cx| {
4680 let project = panel.project.read(cx);
4681 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4682 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
4683 });
4684
4685 toggle_expand_dir(&panel, "root/dir1", cx);
4686
4687 assert_eq!(
4688 visible_entries_as_strings(&panel, 0..20, cx),
4689 &[
4690 separator!("v root"),
4691 separator!(" v dir1 <== selected"),
4692 separator!(" > subdir1"),
4693 separator!(" > subdir2"),
4694 separator!(" > dir2"),
4695 ],
4696 "Subdirs should be collapsed but not folded with auto-fold disabled"
4697 );
4698 }
4699}
4700
4701fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4702 let path = path.as_ref();
4703 panel.update(cx, |panel, cx| {
4704 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4705 let worktree = worktree.read(cx);
4706 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4707 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4708 panel.selection = Some(crate::SelectedEntry {
4709 worktree_id: worktree.id(),
4710 entry_id,
4711 });
4712 return;
4713 }
4714 }
4715 panic!("no worktree for path {:?}", path);
4716 });
4717}
4718
4719fn select_path_with_mark(
4720 panel: &Entity<ProjectPanel>,
4721 path: impl AsRef<Path>,
4722 cx: &mut VisualTestContext,
4723) {
4724 let path = path.as_ref();
4725 panel.update(cx, |panel, cx| {
4726 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4727 let worktree = worktree.read(cx);
4728 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4729 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4730 let entry = crate::SelectedEntry {
4731 worktree_id: worktree.id(),
4732 entry_id,
4733 };
4734 if !panel.marked_entries.contains(&entry) {
4735 panel.marked_entries.insert(entry);
4736 }
4737 panel.selection = Some(entry);
4738 return;
4739 }
4740 }
4741 panic!("no worktree for path {:?}", path);
4742 });
4743}
4744
4745fn find_project_entry(
4746 panel: &Entity<ProjectPanel>,
4747 path: impl AsRef<Path>,
4748 cx: &mut VisualTestContext,
4749) -> Option<ProjectEntryId> {
4750 let path = path.as_ref();
4751 panel.update(cx, |panel, cx| {
4752 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4753 let worktree = worktree.read(cx);
4754 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4755 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4756 }
4757 }
4758 panic!("no worktree for path {path:?}");
4759 })
4760}
4761
4762fn visible_entries_as_strings(
4763 panel: &Entity<ProjectPanel>,
4764 range: Range<usize>,
4765 cx: &mut VisualTestContext,
4766) -> Vec<String> {
4767 let mut result = Vec::new();
4768 let mut project_entries = HashSet::default();
4769 let mut has_editor = false;
4770
4771 panel.update_in(cx, |panel, window, cx| {
4772 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
4773 if details.is_editing {
4774 assert!(!has_editor, "duplicate editor entry");
4775 has_editor = true;
4776 } else {
4777 assert!(
4778 project_entries.insert(project_entry),
4779 "duplicate project entry {:?} {:?}",
4780 project_entry,
4781 details
4782 );
4783 }
4784
4785 let indent = " ".repeat(details.depth);
4786 let icon = if details.kind.is_dir() {
4787 if details.is_expanded {
4788 "v "
4789 } else {
4790 "> "
4791 }
4792 } else {
4793 " "
4794 };
4795 let name = if details.is_editing {
4796 format!("[EDITOR: '{}']", details.filename)
4797 } else if details.is_processing {
4798 format!("[PROCESSING: '{}']", details.filename)
4799 } else {
4800 details.filename.clone()
4801 };
4802 let selected = if details.is_selected {
4803 " <== selected"
4804 } else {
4805 ""
4806 };
4807 let marked = if details.is_marked {
4808 " <== marked"
4809 } else {
4810 ""
4811 };
4812
4813 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4814 });
4815 });
4816
4817 result
4818}
4819
4820fn init_test(cx: &mut TestAppContext) {
4821 cx.update(|cx| {
4822 let settings_store = SettingsStore::test(cx);
4823 cx.set_global(settings_store);
4824 init_settings(cx);
4825 theme::init(theme::LoadThemes::JustBase, cx);
4826 language::init(cx);
4827 editor::init_settings(cx);
4828 crate::init(cx);
4829 workspace::init_settings(cx);
4830 client::init_settings(cx);
4831 Project::init_settings(cx);
4832
4833 cx.update_global::<SettingsStore, _>(|store, cx| {
4834 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4835 project_panel_settings.auto_fold_dirs = Some(false);
4836 });
4837 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4838 worktree_settings.file_scan_exclusions = Some(Vec::new());
4839 });
4840 });
4841 });
4842}
4843
4844fn init_test_with_editor(cx: &mut TestAppContext) {
4845 cx.update(|cx| {
4846 let app_state = AppState::test(cx);
4847 theme::init(theme::LoadThemes::JustBase, cx);
4848 init_settings(cx);
4849 language::init(cx);
4850 editor::init(cx);
4851 crate::init(cx);
4852 workspace::init(app_state.clone(), cx);
4853 Project::init_settings(cx);
4854
4855 cx.update_global::<SettingsStore, _>(|store, cx| {
4856 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4857 project_panel_settings.auto_fold_dirs = Some(false);
4858 });
4859 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4860 worktree_settings.file_scan_exclusions = Some(Vec::new());
4861 });
4862 });
4863 });
4864}
4865
4866fn ensure_single_file_is_opened(
4867 window: &WindowHandle<Workspace>,
4868 expected_path: &str,
4869 cx: &mut TestAppContext,
4870) {
4871 window
4872 .update(cx, |workspace, _, cx| {
4873 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4874 assert_eq!(worktrees.len(), 1);
4875 let worktree_id = worktrees[0].read(cx).id();
4876
4877 let open_project_paths = workspace
4878 .panes()
4879 .iter()
4880 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4881 .collect::<Vec<_>>();
4882 assert_eq!(
4883 open_project_paths,
4884 vec![ProjectPath {
4885 worktree_id,
4886 path: Arc::from(Path::new(expected_path))
4887 }],
4888 "Should have opened file, selected in project panel"
4889 );
4890 })
4891 .unwrap();
4892}
4893
4894fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
4895 assert!(
4896 !cx.has_pending_prompt(),
4897 "Should have no prompts before the deletion"
4898 );
4899 panel.update_in(cx, |panel, window, cx| {
4900 panel.delete(&Delete { skip_prompt: false }, window, cx)
4901 });
4902 assert!(
4903 cx.has_pending_prompt(),
4904 "Should have a prompt after the deletion"
4905 );
4906 cx.simulate_prompt_answer("Delete");
4907 assert!(
4908 !cx.has_pending_prompt(),
4909 "Should have no prompts after prompt was replied to"
4910 );
4911 cx.executor().run_until_parked();
4912}
4913
4914fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
4915 assert!(
4916 !cx.has_pending_prompt(),
4917 "Should have no prompts before the deletion"
4918 );
4919 panel.update_in(cx, |panel, window, cx| {
4920 panel.delete(&Delete { skip_prompt: true }, window, cx)
4921 });
4922 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4923 cx.executor().run_until_parked();
4924}
4925
4926fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
4927 assert!(
4928 !cx.has_pending_prompt(),
4929 "Should have no prompts after deletion operation closes the file"
4930 );
4931 workspace
4932 .read_with(cx, |workspace, cx| {
4933 let open_project_paths = workspace
4934 .panes()
4935 .iter()
4936 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4937 .collect::<Vec<_>>();
4938 assert!(
4939 open_project_paths.is_empty(),
4940 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4941 );
4942 })
4943 .unwrap();
4944}
4945
4946struct TestProjectItemView {
4947 focus_handle: FocusHandle,
4948 path: ProjectPath,
4949}
4950
4951struct TestProjectItem {
4952 path: ProjectPath,
4953}
4954
4955impl project::ProjectItem for TestProjectItem {
4956 fn try_open(
4957 _project: &Entity<Project>,
4958 path: &ProjectPath,
4959 cx: &mut App,
4960 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
4961 let path = path.clone();
4962 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
4963 }
4964
4965 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
4966 None
4967 }
4968
4969 fn project_path(&self, _: &App) -> Option<ProjectPath> {
4970 Some(self.path.clone())
4971 }
4972
4973 fn is_dirty(&self) -> bool {
4974 false
4975 }
4976}
4977
4978impl ProjectItem for TestProjectItemView {
4979 type Item = TestProjectItem;
4980
4981 fn for_project_item(
4982 _: Entity<Project>,
4983 project_item: Entity<Self::Item>,
4984 _: &mut Window,
4985 cx: &mut Context<Self>,
4986 ) -> Self
4987 where
4988 Self: Sized,
4989 {
4990 Self {
4991 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
4992 focus_handle: cx.focus_handle(),
4993 }
4994 }
4995}
4996
4997impl Item for TestProjectItemView {
4998 type Event = ();
4999}
5000
5001impl EventEmitter<()> for TestProjectItemView {}
5002
5003impl Focusable for TestProjectItemView {
5004 fn focus_handle(&self, _: &App) -> FocusHandle {
5005 self.focus_handle.clone()
5006 }
5007}
5008
5009impl Render for TestProjectItemView {
5010 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
5011 Empty
5012 }
5013}