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;
10use workspace::{
11 AppState, ItemHandle, Pane,
12 item::{Item, ProjectItem},
13 register_project_item,
14};
15
16#[gpui::test]
17async fn test_visible_list(cx: &mut gpui::TestAppContext) {
18 init_test(cx);
19
20 let fs = FakeFs::new(cx.executor());
21 fs.insert_tree(
22 "/root1",
23 json!({
24 ".dockerignore": "",
25 ".git": {
26 "HEAD": "",
27 },
28 "a": {
29 "0": { "q": "", "r": "", "s": "" },
30 "1": { "t": "", "u": "" },
31 "2": { "v": "", "w": "", "x": "", "y": "" },
32 },
33 "b": {
34 "3": { "Q": "" },
35 "4": { "R": "", "S": "", "T": "", "U": "" },
36 },
37 "C": {
38 "5": {},
39 "6": { "V": "", "W": "" },
40 "7": { "X": "" },
41 "8": { "Y": {}, "Z": "" }
42 }
43 }),
44 )
45 .await;
46 fs.insert_tree(
47 "/root2",
48 json!({
49 "d": {
50 "9": ""
51 },
52 "e": {}
53 }),
54 )
55 .await;
56
57 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
58 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
59 let cx = &mut VisualTestContext::from_window(*workspace, cx);
60 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
61 assert_eq!(
62 visible_entries_as_strings(&panel, 0..50, cx),
63 &[
64 "v root1",
65 " > .git",
66 " > a",
67 " > b",
68 " > C",
69 " .dockerignore",
70 "v root2",
71 " > d",
72 " > e",
73 ]
74 );
75
76 toggle_expand_dir(&panel, "root1/b", cx);
77 assert_eq!(
78 visible_entries_as_strings(&panel, 0..50, cx),
79 &[
80 "v root1",
81 " > .git",
82 " > a",
83 " v b <== selected",
84 " > 3",
85 " > 4",
86 " > C",
87 " .dockerignore",
88 "v root2",
89 " > d",
90 " > e",
91 ]
92 );
93
94 assert_eq!(
95 visible_entries_as_strings(&panel, 6..9, cx),
96 &[
97 //
98 " > C",
99 " .dockerignore",
100 "v root2",
101 ]
102 );
103}
104
105#[gpui::test]
106async fn test_opening_file(cx: &mut gpui::TestAppContext) {
107 init_test_with_editor(cx);
108
109 let fs = FakeFs::new(cx.executor());
110 fs.insert_tree(
111 path!("/src"),
112 json!({
113 "test": {
114 "first.rs": "// First Rust file",
115 "second.rs": "// Second Rust file",
116 "third.rs": "// Third Rust file",
117 }
118 }),
119 )
120 .await;
121
122 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
123 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
124 let cx = &mut VisualTestContext::from_window(*workspace, cx);
125 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
126
127 toggle_expand_dir(&panel, "src/test", cx);
128 select_path(&panel, "src/test/first.rs", cx);
129 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
130 cx.executor().run_until_parked();
131 assert_eq!(
132 visible_entries_as_strings(&panel, 0..10, cx),
133 &[
134 "v src",
135 " v test",
136 " first.rs <== selected <== marked",
137 " second.rs",
138 " third.rs"
139 ]
140 );
141 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
142
143 select_path(&panel, "src/test/second.rs", cx);
144 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
145 cx.executor().run_until_parked();
146 assert_eq!(
147 visible_entries_as_strings(&panel, 0..10, cx),
148 &[
149 "v src",
150 " v test",
151 " first.rs",
152 " second.rs <== selected <== marked",
153 " third.rs"
154 ]
155 );
156 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
157}
158
159#[gpui::test]
160async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
161 init_test(cx);
162 cx.update(|cx| {
163 cx.update_global::<SettingsStore, _>(|store, cx| {
164 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
165 worktree_settings.file_scan_exclusions =
166 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
167 });
168 });
169 });
170
171 let fs = FakeFs::new(cx.background_executor.clone());
172 fs.insert_tree(
173 "/root1",
174 json!({
175 ".dockerignore": "",
176 ".git": {
177 "HEAD": "",
178 },
179 "a": {
180 "0": { "q": "", "r": "", "s": "" },
181 "1": { "t": "", "u": "" },
182 "2": { "v": "", "w": "", "x": "", "y": "" },
183 },
184 "b": {
185 "3": { "Q": "" },
186 "4": { "R": "", "S": "", "T": "", "U": "" },
187 },
188 "C": {
189 "5": {},
190 "6": { "V": "", "W": "" },
191 "7": { "X": "" },
192 "8": { "Y": {}, "Z": "" }
193 }
194 }),
195 )
196 .await;
197 fs.insert_tree(
198 "/root2",
199 json!({
200 "d": {
201 "4": ""
202 },
203 "e": {}
204 }),
205 )
206 .await;
207
208 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
209 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
210 let cx = &mut VisualTestContext::from_window(*workspace, cx);
211 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
212 assert_eq!(
213 visible_entries_as_strings(&panel, 0..50, cx),
214 &[
215 "v root1",
216 " > a",
217 " > b",
218 " > C",
219 " .dockerignore",
220 "v root2",
221 " > d",
222 " > e",
223 ]
224 );
225
226 toggle_expand_dir(&panel, "root1/b", cx);
227 assert_eq!(
228 visible_entries_as_strings(&panel, 0..50, cx),
229 &[
230 "v root1",
231 " > a",
232 " v b <== selected",
233 " > 3",
234 " > C",
235 " .dockerignore",
236 "v root2",
237 " > d",
238 " > e",
239 ]
240 );
241
242 toggle_expand_dir(&panel, "root2/d", cx);
243 assert_eq!(
244 visible_entries_as_strings(&panel, 0..50, cx),
245 &[
246 "v root1",
247 " > a",
248 " v b",
249 " > 3",
250 " > C",
251 " .dockerignore",
252 "v root2",
253 " v d <== selected",
254 " > e",
255 ]
256 );
257
258 toggle_expand_dir(&panel, "root2/e", cx);
259 assert_eq!(
260 visible_entries_as_strings(&panel, 0..50, cx),
261 &[
262 "v root1",
263 " > a",
264 " v b",
265 " > 3",
266 " > C",
267 " .dockerignore",
268 "v root2",
269 " v d",
270 " v e <== selected",
271 ]
272 );
273}
274
275#[gpui::test]
276async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
277 init_test(cx);
278
279 let fs = FakeFs::new(cx.executor());
280 fs.insert_tree(
281 path!("/root1"),
282 json!({
283 "dir_1": {
284 "nested_dir_1": {
285 "nested_dir_2": {
286 "nested_dir_3": {
287 "file_a.java": "// File contents",
288 "file_b.java": "// File contents",
289 "file_c.java": "// File contents",
290 "nested_dir_4": {
291 "nested_dir_5": {
292 "file_d.java": "// File contents",
293 }
294 }
295 }
296 }
297 }
298 }
299 }),
300 )
301 .await;
302 fs.insert_tree(
303 path!("/root2"),
304 json!({
305 "dir_2": {
306 "file_1.java": "// File contents",
307 }
308 }),
309 )
310 .await;
311
312 // Test 1: Multiple worktrees with auto_fold_dirs = true
313 let project = Project::test(
314 fs.clone(),
315 [path!("/root1").as_ref(), path!("/root2").as_ref()],
316 cx,
317 )
318 .await;
319 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
320 let cx = &mut VisualTestContext::from_window(*workspace, cx);
321 cx.update(|_, cx| {
322 let settings = *ProjectPanelSettings::get_global(cx);
323 ProjectPanelSettings::override_global(
324 ProjectPanelSettings {
325 auto_fold_dirs: true,
326 ..settings
327 },
328 cx,
329 );
330 });
331 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
332 assert_eq!(
333 visible_entries_as_strings(&panel, 0..10, cx),
334 &[
335 "v root1",
336 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
337 "v root2",
338 " > dir_2",
339 ]
340 );
341
342 toggle_expand_dir(
343 &panel,
344 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
345 cx,
346 );
347 assert_eq!(
348 visible_entries_as_strings(&panel, 0..10, cx),
349 &[
350 "v root1",
351 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
352 " > nested_dir_4/nested_dir_5",
353 " file_a.java",
354 " file_b.java",
355 " file_c.java",
356 "v root2",
357 " > dir_2",
358 ]
359 );
360
361 toggle_expand_dir(
362 &panel,
363 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
364 cx,
365 );
366 assert_eq!(
367 visible_entries_as_strings(&panel, 0..10, cx),
368 &[
369 "v root1",
370 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
371 " v nested_dir_4/nested_dir_5 <== selected",
372 " file_d.java",
373 " file_a.java",
374 " file_b.java",
375 " file_c.java",
376 "v root2",
377 " > dir_2",
378 ]
379 );
380 toggle_expand_dir(&panel, "root2/dir_2", cx);
381 assert_eq!(
382 visible_entries_as_strings(&panel, 0..10, cx),
383 &[
384 "v root1",
385 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
386 " v nested_dir_4/nested_dir_5",
387 " file_d.java",
388 " file_a.java",
389 " file_b.java",
390 " file_c.java",
391 "v root2",
392 " v dir_2 <== selected",
393 " file_1.java",
394 ]
395 );
396
397 // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
398 {
399 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
400 let workspace =
401 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
402 let cx = &mut VisualTestContext::from_window(*workspace, cx);
403 cx.update(|_, cx| {
404 let settings = *ProjectPanelSettings::get_global(cx);
405 ProjectPanelSettings::override_global(
406 ProjectPanelSettings {
407 auto_fold_dirs: true,
408 hide_root: true,
409 ..settings
410 },
411 cx,
412 );
413 });
414 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
415 assert_eq!(
416 visible_entries_as_strings(&panel, 0..10, cx),
417 &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
418 "Single worktree with hide_root=true should hide root and show auto-folded paths"
419 );
420
421 toggle_expand_dir(
422 &panel,
423 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
424 cx,
425 );
426 assert_eq!(
427 visible_entries_as_strings(&panel, 0..10, cx),
428 &[
429 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
430 " > nested_dir_4/nested_dir_5",
431 " file_a.java",
432 " file_b.java",
433 " file_c.java",
434 ],
435 "Expanded auto-folded path with hidden root should show contents without root prefix"
436 );
437
438 toggle_expand_dir(
439 &panel,
440 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
441 cx,
442 );
443 assert_eq!(
444 visible_entries_as_strings(&panel, 0..10, cx),
445 &[
446 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
447 " v nested_dir_4/nested_dir_5 <== selected",
448 " file_d.java",
449 " file_a.java",
450 " file_b.java",
451 " file_c.java",
452 ],
453 "Nested expansion with hidden root should maintain proper indentation"
454 );
455 }
456}
457
458#[gpui::test(iterations = 30)]
459async fn test_editing_files(cx: &mut gpui::TestAppContext) {
460 init_test(cx);
461
462 let fs = FakeFs::new(cx.executor());
463 fs.insert_tree(
464 "/root1",
465 json!({
466 ".dockerignore": "",
467 ".git": {
468 "HEAD": "",
469 },
470 "a": {
471 "0": { "q": "", "r": "", "s": "" },
472 "1": { "t": "", "u": "" },
473 "2": { "v": "", "w": "", "x": "", "y": "" },
474 },
475 "b": {
476 "3": { "Q": "" },
477 "4": { "R": "", "S": "", "T": "", "U": "" },
478 },
479 "C": {
480 "5": {},
481 "6": { "V": "", "W": "" },
482 "7": { "X": "" },
483 "8": { "Y": {}, "Z": "" }
484 }
485 }),
486 )
487 .await;
488 fs.insert_tree(
489 "/root2",
490 json!({
491 "d": {
492 "9": ""
493 },
494 "e": {}
495 }),
496 )
497 .await;
498
499 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
500 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
501 let cx = &mut VisualTestContext::from_window(*workspace, cx);
502 let panel = workspace
503 .update(cx, |workspace, window, cx| {
504 let panel = ProjectPanel::new(workspace, window, cx);
505 workspace.add_panel(panel.clone(), window, cx);
506 panel
507 })
508 .unwrap();
509
510 select_path(&panel, "root1", cx);
511 assert_eq!(
512 visible_entries_as_strings(&panel, 0..10, cx),
513 &[
514 "v root1 <== selected",
515 " > .git",
516 " > a",
517 " > b",
518 " > C",
519 " .dockerignore",
520 "v root2",
521 " > d",
522 " > e",
523 ]
524 );
525
526 // Add a file with the root folder selected. The filename editor is placed
527 // before the first file in the root folder.
528 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
529 panel.update_in(cx, |panel, window, cx| {
530 assert!(panel.filename_editor.read(cx).is_focused(window));
531 });
532 assert_eq!(
533 visible_entries_as_strings(&panel, 0..10, cx),
534 &[
535 "v root1",
536 " > .git",
537 " > a",
538 " > b",
539 " > C",
540 " [EDITOR: ''] <== selected",
541 " .dockerignore",
542 "v root2",
543 " > d",
544 " > e",
545 ]
546 );
547
548 let confirm = panel.update_in(cx, |panel, window, cx| {
549 panel.filename_editor.update(cx, |editor, cx| {
550 editor.set_text("the-new-filename", window, cx)
551 });
552 panel.confirm_edit(window, cx).unwrap()
553 });
554 assert_eq!(
555 visible_entries_as_strings(&panel, 0..10, cx),
556 &[
557 "v root1",
558 " > .git",
559 " > a",
560 " > b",
561 " > C",
562 " [PROCESSING: 'the-new-filename'] <== selected",
563 " .dockerignore",
564 "v root2",
565 " > d",
566 " > e",
567 ]
568 );
569
570 confirm.await.unwrap();
571 assert_eq!(
572 visible_entries_as_strings(&panel, 0..10, cx),
573 &[
574 "v root1",
575 " > .git",
576 " > a",
577 " > b",
578 " > C",
579 " .dockerignore",
580 " the-new-filename <== selected <== marked",
581 "v root2",
582 " > d",
583 " > e",
584 ]
585 );
586
587 select_path(&panel, "root1/b", cx);
588 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
589 assert_eq!(
590 visible_entries_as_strings(&panel, 0..10, cx),
591 &[
592 "v root1",
593 " > .git",
594 " > a",
595 " v b",
596 " > 3",
597 " > 4",
598 " [EDITOR: ''] <== selected",
599 " > C",
600 " .dockerignore",
601 " the-new-filename",
602 ]
603 );
604
605 panel
606 .update_in(cx, |panel, window, cx| {
607 panel.filename_editor.update(cx, |editor, cx| {
608 editor.set_text("another-filename.txt", window, cx)
609 });
610 panel.confirm_edit(window, cx).unwrap()
611 })
612 .await
613 .unwrap();
614 assert_eq!(
615 visible_entries_as_strings(&panel, 0..10, cx),
616 &[
617 "v root1",
618 " > .git",
619 " > a",
620 " v b",
621 " > 3",
622 " > 4",
623 " another-filename.txt <== selected <== marked",
624 " > C",
625 " .dockerignore",
626 " the-new-filename",
627 ]
628 );
629
630 select_path(&panel, "root1/b/another-filename.txt", cx);
631 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
632 assert_eq!(
633 visible_entries_as_strings(&panel, 0..10, cx),
634 &[
635 "v root1",
636 " > .git",
637 " > a",
638 " v b",
639 " > 3",
640 " > 4",
641 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
642 " > C",
643 " .dockerignore",
644 " the-new-filename",
645 ]
646 );
647
648 let confirm = panel.update_in(cx, |panel, window, cx| {
649 panel.filename_editor.update(cx, |editor, cx| {
650 let file_name_selections = editor.selections.all::<usize>(cx);
651 assert_eq!(
652 file_name_selections.len(),
653 1,
654 "File editing should have a single selection, but got: {file_name_selections:?}"
655 );
656 let file_name_selection = &file_name_selections[0];
657 assert_eq!(
658 file_name_selection.start, 0,
659 "Should select the file name from the start"
660 );
661 assert_eq!(
662 file_name_selection.end,
663 "another-filename".len(),
664 "Should not select file extension"
665 );
666
667 editor.set_text("a-different-filename.tar.gz", window, cx)
668 });
669 panel.confirm_edit(window, cx).unwrap()
670 });
671 assert_eq!(
672 visible_entries_as_strings(&panel, 0..10, cx),
673 &[
674 "v root1",
675 " > .git",
676 " > a",
677 " v b",
678 " > 3",
679 " > 4",
680 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
681 " > C",
682 " .dockerignore",
683 " the-new-filename",
684 ]
685 );
686
687 confirm.await.unwrap();
688 assert_eq!(
689 visible_entries_as_strings(&panel, 0..10, cx),
690 &[
691 "v root1",
692 " > .git",
693 " > a",
694 " v b",
695 " > 3",
696 " > 4",
697 " a-different-filename.tar.gz <== selected",
698 " > C",
699 " .dockerignore",
700 " the-new-filename",
701 ]
702 );
703
704 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
705 assert_eq!(
706 visible_entries_as_strings(&panel, 0..10, cx),
707 &[
708 "v root1",
709 " > .git",
710 " > a",
711 " v b",
712 " > 3",
713 " > 4",
714 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
715 " > C",
716 " .dockerignore",
717 " the-new-filename",
718 ]
719 );
720
721 panel.update_in(cx, |panel, window, cx| {
722 panel.filename_editor.update(cx, |editor, cx| {
723 let file_name_selections = editor.selections.all::<usize>(cx);
724 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
725 let file_name_selection = &file_name_selections[0];
726 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
727 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..");
728
729 });
730 panel.cancel(&menu::Cancel, window, cx)
731 });
732
733 panel.update_in(cx, |panel, window, cx| {
734 panel.new_directory(&NewDirectory, window, cx)
735 });
736 assert_eq!(
737 visible_entries_as_strings(&panel, 0..10, cx),
738 &[
739 "v root1",
740 " > .git",
741 " > a",
742 " v b",
743 " > [EDITOR: ''] <== selected",
744 " > 3",
745 " > 4",
746 " a-different-filename.tar.gz",
747 " > C",
748 " .dockerignore",
749 ]
750 );
751
752 let confirm = panel.update_in(cx, |panel, window, cx| {
753 panel
754 .filename_editor
755 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
756 panel.confirm_edit(window, cx).unwrap()
757 });
758 panel.update_in(cx, |panel, window, cx| {
759 panel.select_next(&Default::default(), window, cx)
760 });
761 assert_eq!(
762 visible_entries_as_strings(&panel, 0..10, cx),
763 &[
764 "v root1",
765 " > .git",
766 " > a",
767 " v b",
768 " > [PROCESSING: 'new-dir']",
769 " > 3 <== selected",
770 " > 4",
771 " a-different-filename.tar.gz",
772 " > C",
773 " .dockerignore",
774 ]
775 );
776
777 confirm.await.unwrap();
778 assert_eq!(
779 visible_entries_as_strings(&panel, 0..10, cx),
780 &[
781 "v root1",
782 " > .git",
783 " > a",
784 " v b",
785 " > 3 <== selected",
786 " > 4",
787 " > new-dir",
788 " a-different-filename.tar.gz",
789 " > C",
790 " .dockerignore",
791 ]
792 );
793
794 panel.update_in(cx, |panel, window, cx| {
795 panel.rename(&Default::default(), window, cx)
796 });
797 assert_eq!(
798 visible_entries_as_strings(&panel, 0..10, cx),
799 &[
800 "v root1",
801 " > .git",
802 " > a",
803 " v b",
804 " > [EDITOR: '3'] <== selected",
805 " > 4",
806 " > new-dir",
807 " a-different-filename.tar.gz",
808 " > C",
809 " .dockerignore",
810 ]
811 );
812
813 // Dismiss the rename editor when it loses focus.
814 workspace.update(cx, |_, window, _| window.blur()).unwrap();
815 assert_eq!(
816 visible_entries_as_strings(&panel, 0..10, cx),
817 &[
818 "v root1",
819 " > .git",
820 " > a",
821 " v b",
822 " > 3 <== selected",
823 " > 4",
824 " > new-dir",
825 " a-different-filename.tar.gz",
826 " > C",
827 " .dockerignore",
828 ]
829 );
830
831 // Test empty filename and filename with only whitespace
832 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
833 assert_eq!(
834 visible_entries_as_strings(&panel, 0..10, cx),
835 &[
836 "v root1",
837 " > .git",
838 " > a",
839 " v b",
840 " v 3",
841 " [EDITOR: ''] <== selected",
842 " Q",
843 " > 4",
844 " > new-dir",
845 " a-different-filename.tar.gz",
846 ]
847 );
848 panel.update_in(cx, |panel, window, cx| {
849 panel.filename_editor.update(cx, |editor, cx| {
850 editor.set_text("", window, cx);
851 });
852 assert!(panel.confirm_edit(window, cx).is_none());
853 panel.filename_editor.update(cx, |editor, cx| {
854 editor.set_text(" ", window, cx);
855 });
856 assert!(panel.confirm_edit(window, cx).is_none());
857 panel.cancel(&menu::Cancel, window, cx)
858 });
859 assert_eq!(
860 visible_entries_as_strings(&panel, 0..10, cx),
861 &[
862 "v root1",
863 " > .git",
864 " > a",
865 " v b",
866 " v 3 <== selected",
867 " Q",
868 " > 4",
869 " > new-dir",
870 " a-different-filename.tar.gz",
871 " > C",
872 ]
873 );
874}
875
876#[gpui::test(iterations = 10)]
877async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
878 init_test(cx);
879
880 let fs = FakeFs::new(cx.executor());
881 fs.insert_tree(
882 "/root1",
883 json!({
884 ".dockerignore": "",
885 ".git": {
886 "HEAD": "",
887 },
888 "a": {
889 "0": { "q": "", "r": "", "s": "" },
890 "1": { "t": "", "u": "" },
891 "2": { "v": "", "w": "", "x": "", "y": "" },
892 },
893 "b": {
894 "3": { "Q": "" },
895 "4": { "R": "", "S": "", "T": "", "U": "" },
896 },
897 "C": {
898 "5": {},
899 "6": { "V": "", "W": "" },
900 "7": { "X": "" },
901 "8": { "Y": {}, "Z": "" }
902 }
903 }),
904 )
905 .await;
906 fs.insert_tree(
907 "/root2",
908 json!({
909 "d": {
910 "9": ""
911 },
912 "e": {}
913 }),
914 )
915 .await;
916
917 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
918 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
919 let cx = &mut VisualTestContext::from_window(*workspace, cx);
920 let panel = workspace
921 .update(cx, |workspace, window, cx| {
922 let panel = ProjectPanel::new(workspace, window, cx);
923 workspace.add_panel(panel.clone(), window, cx);
924 panel
925 })
926 .unwrap();
927
928 select_path(&panel, "root1", cx);
929 assert_eq!(
930 visible_entries_as_strings(&panel, 0..10, cx),
931 &[
932 "v root1 <== selected",
933 " > .git",
934 " > a",
935 " > b",
936 " > C",
937 " .dockerignore",
938 "v root2",
939 " > d",
940 " > e",
941 ]
942 );
943
944 // Add a file with the root folder selected. The filename editor is placed
945 // before the first file in the root folder.
946 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
947 panel.update_in(cx, |panel, window, cx| {
948 assert!(panel.filename_editor.read(cx).is_focused(window));
949 });
950 assert_eq!(
951 visible_entries_as_strings(&panel, 0..10, cx),
952 &[
953 "v root1",
954 " > .git",
955 " > a",
956 " > b",
957 " > C",
958 " [EDITOR: ''] <== selected",
959 " .dockerignore",
960 "v root2",
961 " > d",
962 " > e",
963 ]
964 );
965
966 let confirm = panel.update_in(cx, |panel, window, cx| {
967 panel.filename_editor.update(cx, |editor, cx| {
968 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
969 });
970 panel.confirm_edit(window, cx).unwrap()
971 });
972
973 assert_eq!(
974 visible_entries_as_strings(&panel, 0..10, cx),
975 &[
976 "v root1",
977 " > .git",
978 " > a",
979 " > b",
980 " > C",
981 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
982 " .dockerignore",
983 "v root2",
984 " > d",
985 " > e",
986 ]
987 );
988
989 confirm.await.unwrap();
990 assert_eq!(
991 visible_entries_as_strings(&panel, 0..13, cx),
992 &[
993 "v root1",
994 " > .git",
995 " > a",
996 " > b",
997 " v bdir1",
998 " v dir2",
999 " the-new-filename <== selected <== marked",
1000 " > C",
1001 " .dockerignore",
1002 "v root2",
1003 " > d",
1004 " > e",
1005 ]
1006 );
1007}
1008
1009#[gpui::test]
1010async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
1011 init_test(cx);
1012
1013 let fs = FakeFs::new(cx.executor());
1014 fs.insert_tree(
1015 path!("/root1"),
1016 json!({
1017 ".dockerignore": "",
1018 ".git": {
1019 "HEAD": "",
1020 },
1021 }),
1022 )
1023 .await;
1024
1025 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
1026 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1027 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1028 let panel = workspace
1029 .update(cx, |workspace, window, cx| {
1030 let panel = ProjectPanel::new(workspace, window, cx);
1031 workspace.add_panel(panel.clone(), window, cx);
1032 panel
1033 })
1034 .unwrap();
1035
1036 select_path(&panel, "root1", cx);
1037 assert_eq!(
1038 visible_entries_as_strings(&panel, 0..10, cx),
1039 &["v root1 <== selected", " > .git", " .dockerignore",]
1040 );
1041
1042 // Add a file with the root folder selected. The filename editor is placed
1043 // before the first file in the root folder.
1044 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1045 panel.update_in(cx, |panel, window, cx| {
1046 assert!(panel.filename_editor.read(cx).is_focused(window));
1047 });
1048 assert_eq!(
1049 visible_entries_as_strings(&panel, 0..10, cx),
1050 &[
1051 "v root1",
1052 " > .git",
1053 " [EDITOR: ''] <== selected",
1054 " .dockerignore",
1055 ]
1056 );
1057
1058 let confirm = panel.update_in(cx, |panel, window, cx| {
1059 // If we want to create a subdirectory, there should be no prefix slash.
1060 panel
1061 .filename_editor
1062 .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
1063 panel.confirm_edit(window, cx).unwrap()
1064 });
1065
1066 assert_eq!(
1067 visible_entries_as_strings(&panel, 0..10, cx),
1068 &[
1069 "v root1",
1070 " > .git",
1071 " [PROCESSING: 'new_dir/'] <== selected",
1072 " .dockerignore",
1073 ]
1074 );
1075
1076 confirm.await.unwrap();
1077 assert_eq!(
1078 visible_entries_as_strings(&panel, 0..10, cx),
1079 &[
1080 "v root1",
1081 " > .git",
1082 " v new_dir <== selected",
1083 " .dockerignore",
1084 ]
1085 );
1086
1087 // Test filename with whitespace
1088 select_path(&panel, "root1", cx);
1089 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1090 let confirm = panel.update_in(cx, |panel, window, cx| {
1091 // If we want to create a subdirectory, there should be no prefix slash.
1092 panel
1093 .filename_editor
1094 .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
1095 panel.confirm_edit(window, cx).unwrap()
1096 });
1097 confirm.await.unwrap();
1098 assert_eq!(
1099 visible_entries_as_strings(&panel, 0..10, cx),
1100 &[
1101 "v root1",
1102 " > .git",
1103 " v new dir 2 <== selected",
1104 " v new_dir",
1105 " .dockerignore",
1106 ]
1107 );
1108
1109 // Test filename ends with "\"
1110 #[cfg(target_os = "windows")]
1111 {
1112 select_path(&panel, "root1", cx);
1113 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1114 let confirm = panel.update_in(cx, |panel, window, cx| {
1115 // If we want to create a subdirectory, there should be no prefix slash.
1116 panel
1117 .filename_editor
1118 .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
1119 panel.confirm_edit(window, cx).unwrap()
1120 });
1121 confirm.await.unwrap();
1122 assert_eq!(
1123 visible_entries_as_strings(&panel, 0..10, cx),
1124 &[
1125 "v root1",
1126 " > .git",
1127 " v new dir 2",
1128 " v new_dir",
1129 " v new_dir_3 <== selected",
1130 " .dockerignore",
1131 ]
1132 );
1133 }
1134}
1135
1136#[gpui::test]
1137async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1138 init_test(cx);
1139
1140 let fs = FakeFs::new(cx.executor());
1141 fs.insert_tree(
1142 "/root1",
1143 json!({
1144 "one.two.txt": "",
1145 "one.txt": ""
1146 }),
1147 )
1148 .await;
1149
1150 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1151 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1152 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1153 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1154
1155 panel.update_in(cx, |panel, window, cx| {
1156 panel.select_next(&Default::default(), window, cx);
1157 panel.select_next(&Default::default(), window, cx);
1158 });
1159
1160 assert_eq!(
1161 visible_entries_as_strings(&panel, 0..50, cx),
1162 &[
1163 //
1164 "v root1",
1165 " one.txt <== selected",
1166 " one.two.txt",
1167 ]
1168 );
1169
1170 // Regression test - file name is created correctly when
1171 // the copied file's name contains multiple dots.
1172 panel.update_in(cx, |panel, window, cx| {
1173 panel.copy(&Default::default(), window, cx);
1174 panel.paste(&Default::default(), window, cx);
1175 });
1176 cx.executor().run_until_parked();
1177
1178 assert_eq!(
1179 visible_entries_as_strings(&panel, 0..50, cx),
1180 &[
1181 //
1182 "v root1",
1183 " one.txt",
1184 " [EDITOR: 'one copy.txt'] <== selected <== marked",
1185 " one.two.txt",
1186 ]
1187 );
1188
1189 panel.update_in(cx, |panel, window, cx| {
1190 panel.filename_editor.update(cx, |editor, cx| {
1191 let file_name_selections = editor.selections.all::<usize>(cx);
1192 assert_eq!(
1193 file_name_selections.len(),
1194 1,
1195 "File editing should have a single selection, but got: {file_name_selections:?}"
1196 );
1197 let file_name_selection = &file_name_selections[0];
1198 assert_eq!(
1199 file_name_selection.start,
1200 "one".len(),
1201 "Should select the file name disambiguation after the original file name"
1202 );
1203 assert_eq!(
1204 file_name_selection.end,
1205 "one copy".len(),
1206 "Should select the file name disambiguation until the extension"
1207 );
1208 });
1209 assert!(panel.confirm_edit(window, cx).is_none());
1210 });
1211
1212 panel.update_in(cx, |panel, window, cx| {
1213 panel.paste(&Default::default(), window, cx);
1214 });
1215 cx.executor().run_until_parked();
1216
1217 assert_eq!(
1218 visible_entries_as_strings(&panel, 0..50, cx),
1219 &[
1220 //
1221 "v root1",
1222 " one.txt",
1223 " one copy.txt",
1224 " [EDITOR: 'one copy 1.txt'] <== selected <== marked",
1225 " one.two.txt",
1226 ]
1227 );
1228
1229 panel.update_in(cx, |panel, window, cx| {
1230 assert!(panel.confirm_edit(window, cx).is_none())
1231 });
1232}
1233
1234#[gpui::test]
1235async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
1236 init_test(cx);
1237
1238 let fs = FakeFs::new(cx.executor());
1239 fs.insert_tree(
1240 "/root",
1241 json!({
1242 "one.txt": "",
1243 "two.txt": "",
1244 "a": {},
1245 "b": {}
1246 }),
1247 )
1248 .await;
1249
1250 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1251 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1252 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1253 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1254
1255 select_path_with_mark(&panel, "root/one.txt", cx);
1256 select_path_with_mark(&panel, "root/two.txt", cx);
1257
1258 assert_eq!(
1259 visible_entries_as_strings(&panel, 0..50, cx),
1260 &[
1261 "v root",
1262 " > a",
1263 " > b",
1264 " one.txt <== marked",
1265 " two.txt <== selected <== marked",
1266 ]
1267 );
1268
1269 panel.update_in(cx, |panel, window, cx| {
1270 panel.cut(&Default::default(), window, cx);
1271 });
1272
1273 select_path(&panel, "root/a", cx);
1274
1275 panel.update_in(cx, |panel, window, cx| {
1276 panel.paste(&Default::default(), window, cx);
1277 });
1278 cx.executor().run_until_parked();
1279
1280 assert_eq!(
1281 visible_entries_as_strings(&panel, 0..50, cx),
1282 &[
1283 "v root",
1284 " v a",
1285 " one.txt <== marked",
1286 " two.txt <== selected <== marked",
1287 " > b",
1288 ],
1289 "Cut entries should be moved on first paste."
1290 );
1291
1292 panel.update_in(cx, |panel, window, cx| {
1293 panel.cancel(&menu::Cancel {}, window, cx)
1294 });
1295 cx.executor().run_until_parked();
1296
1297 select_path(&panel, "root/b", cx);
1298
1299 panel.update_in(cx, |panel, window, cx| {
1300 panel.paste(&Default::default(), window, cx);
1301 });
1302 cx.executor().run_until_parked();
1303
1304 assert_eq!(
1305 visible_entries_as_strings(&panel, 0..50, cx),
1306 &[
1307 "v root",
1308 " v a",
1309 " one.txt",
1310 " two.txt",
1311 " v b",
1312 " one.txt",
1313 " two.txt <== selected",
1314 ],
1315 "Cut entries should only be copied for the second paste!"
1316 );
1317}
1318
1319#[gpui::test]
1320async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1321 init_test(cx);
1322
1323 let fs = FakeFs::new(cx.executor());
1324 fs.insert_tree(
1325 "/root1",
1326 json!({
1327 "one.txt": "",
1328 "two.txt": "",
1329 "three.txt": "",
1330 "a": {
1331 "0": { "q": "", "r": "", "s": "" },
1332 "1": { "t": "", "u": "" },
1333 "2": { "v": "", "w": "", "x": "", "y": "" },
1334 },
1335 }),
1336 )
1337 .await;
1338
1339 fs.insert_tree(
1340 "/root2",
1341 json!({
1342 "one.txt": "",
1343 "two.txt": "",
1344 "four.txt": "",
1345 "b": {
1346 "3": { "Q": "" },
1347 "4": { "R": "", "S": "", "T": "", "U": "" },
1348 },
1349 }),
1350 )
1351 .await;
1352
1353 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1354 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1355 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1356 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1357
1358 select_path(&panel, "root1/three.txt", cx);
1359 panel.update_in(cx, |panel, window, cx| {
1360 panel.cut(&Default::default(), window, cx);
1361 });
1362
1363 select_path(&panel, "root2/one.txt", cx);
1364 panel.update_in(cx, |panel, window, cx| {
1365 panel.select_next(&Default::default(), window, cx);
1366 panel.paste(&Default::default(), window, cx);
1367 });
1368 cx.executor().run_until_parked();
1369 assert_eq!(
1370 visible_entries_as_strings(&panel, 0..50, cx),
1371 &[
1372 //
1373 "v root1",
1374 " > a",
1375 " one.txt",
1376 " two.txt",
1377 "v root2",
1378 " > b",
1379 " four.txt",
1380 " one.txt",
1381 " three.txt <== selected <== marked",
1382 " two.txt",
1383 ]
1384 );
1385
1386 select_path(&panel, "root1/a", cx);
1387 panel.update_in(cx, |panel, window, cx| {
1388 panel.cut(&Default::default(), window, cx);
1389 });
1390 select_path(&panel, "root2/two.txt", cx);
1391 panel.update_in(cx, |panel, window, cx| {
1392 panel.select_next(&Default::default(), window, cx);
1393 panel.paste(&Default::default(), window, cx);
1394 });
1395
1396 cx.executor().run_until_parked();
1397 assert_eq!(
1398 visible_entries_as_strings(&panel, 0..50, cx),
1399 &[
1400 //
1401 "v root1",
1402 " one.txt",
1403 " two.txt",
1404 "v root2",
1405 " > a <== selected",
1406 " > b",
1407 " four.txt",
1408 " one.txt",
1409 " three.txt <== marked",
1410 " two.txt",
1411 ]
1412 );
1413}
1414
1415#[gpui::test]
1416async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1417 init_test(cx);
1418
1419 let fs = FakeFs::new(cx.executor());
1420 fs.insert_tree(
1421 "/root1",
1422 json!({
1423 "one.txt": "",
1424 "two.txt": "",
1425 "three.txt": "",
1426 "a": {
1427 "0": { "q": "", "r": "", "s": "" },
1428 "1": { "t": "", "u": "" },
1429 "2": { "v": "", "w": "", "x": "", "y": "" },
1430 },
1431 }),
1432 )
1433 .await;
1434
1435 fs.insert_tree(
1436 "/root2",
1437 json!({
1438 "one.txt": "",
1439 "two.txt": "",
1440 "four.txt": "",
1441 "b": {
1442 "3": { "Q": "" },
1443 "4": { "R": "", "S": "", "T": "", "U": "" },
1444 },
1445 }),
1446 )
1447 .await;
1448
1449 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1450 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1451 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1452 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1453
1454 select_path(&panel, "root1/three.txt", cx);
1455 panel.update_in(cx, |panel, window, cx| {
1456 panel.copy(&Default::default(), window, cx);
1457 });
1458
1459 select_path(&panel, "root2/one.txt", cx);
1460 panel.update_in(cx, |panel, window, cx| {
1461 panel.select_next(&Default::default(), window, cx);
1462 panel.paste(&Default::default(), window, cx);
1463 });
1464 cx.executor().run_until_parked();
1465 assert_eq!(
1466 visible_entries_as_strings(&panel, 0..50, cx),
1467 &[
1468 //
1469 "v root1",
1470 " > a",
1471 " one.txt",
1472 " three.txt",
1473 " two.txt",
1474 "v root2",
1475 " > b",
1476 " four.txt",
1477 " one.txt",
1478 " three.txt <== selected <== marked",
1479 " two.txt",
1480 ]
1481 );
1482
1483 select_path(&panel, "root1/three.txt", cx);
1484 panel.update_in(cx, |panel, window, cx| {
1485 panel.copy(&Default::default(), window, cx);
1486 });
1487 select_path(&panel, "root2/two.txt", cx);
1488 panel.update_in(cx, |panel, window, cx| {
1489 panel.select_next(&Default::default(), window, cx);
1490 panel.paste(&Default::default(), window, cx);
1491 });
1492
1493 cx.executor().run_until_parked();
1494 assert_eq!(
1495 visible_entries_as_strings(&panel, 0..50, cx),
1496 &[
1497 //
1498 "v root1",
1499 " > a",
1500 " one.txt",
1501 " three.txt",
1502 " two.txt",
1503 "v root2",
1504 " > b",
1505 " four.txt",
1506 " one.txt",
1507 " three.txt",
1508 " [EDITOR: 'three copy.txt'] <== selected <== marked",
1509 " two.txt",
1510 ]
1511 );
1512
1513 panel.update_in(cx, |panel, window, cx| {
1514 panel.cancel(&menu::Cancel {}, window, cx)
1515 });
1516 cx.executor().run_until_parked();
1517
1518 select_path(&panel, "root1/a", cx);
1519 panel.update_in(cx, |panel, window, cx| {
1520 panel.copy(&Default::default(), window, cx);
1521 });
1522 select_path(&panel, "root2/two.txt", cx);
1523 panel.update_in(cx, |panel, window, cx| {
1524 panel.select_next(&Default::default(), window, cx);
1525 panel.paste(&Default::default(), window, cx);
1526 });
1527
1528 cx.executor().run_until_parked();
1529 assert_eq!(
1530 visible_entries_as_strings(&panel, 0..50, cx),
1531 &[
1532 //
1533 "v root1",
1534 " > a",
1535 " one.txt",
1536 " three.txt",
1537 " two.txt",
1538 "v root2",
1539 " > a <== selected",
1540 " > b",
1541 " four.txt",
1542 " one.txt",
1543 " three.txt",
1544 " three copy.txt",
1545 " two.txt",
1546 ]
1547 );
1548}
1549
1550#[gpui::test]
1551async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1552 init_test(cx);
1553
1554 let fs = FakeFs::new(cx.executor());
1555 fs.insert_tree(
1556 "/root",
1557 json!({
1558 "a": {
1559 "one.txt": "",
1560 "two.txt": "",
1561 "inner_dir": {
1562 "three.txt": "",
1563 "four.txt": "",
1564 }
1565 },
1566 "b": {}
1567 }),
1568 )
1569 .await;
1570
1571 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1572 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1573 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1574 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1575
1576 select_path(&panel, "root/a", cx);
1577 panel.update_in(cx, |panel, window, cx| {
1578 panel.copy(&Default::default(), window, cx);
1579 panel.select_next(&Default::default(), window, cx);
1580 panel.paste(&Default::default(), window, cx);
1581 });
1582 cx.executor().run_until_parked();
1583
1584 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1585 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1586
1587 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1588 assert_ne!(
1589 pasted_dir_file, None,
1590 "Pasted directory file should have an entry"
1591 );
1592
1593 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1594 assert_ne!(
1595 pasted_dir_inner_dir, None,
1596 "Directories inside pasted directory should have an entry"
1597 );
1598
1599 toggle_expand_dir(&panel, "root/b/a", cx);
1600 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1601
1602 assert_eq!(
1603 visible_entries_as_strings(&panel, 0..50, cx),
1604 &[
1605 //
1606 "v root",
1607 " > a",
1608 " v b",
1609 " v a",
1610 " v inner_dir <== selected",
1611 " four.txt",
1612 " three.txt",
1613 " one.txt",
1614 " two.txt",
1615 ]
1616 );
1617
1618 select_path(&panel, "root", cx);
1619 panel.update_in(cx, |panel, window, cx| {
1620 panel.paste(&Default::default(), window, cx)
1621 });
1622 cx.executor().run_until_parked();
1623 assert_eq!(
1624 visible_entries_as_strings(&panel, 0..50, cx),
1625 &[
1626 //
1627 "v root",
1628 " > a",
1629 " > [EDITOR: 'a copy'] <== selected",
1630 " v b",
1631 " v a",
1632 " v inner_dir",
1633 " four.txt",
1634 " three.txt",
1635 " one.txt",
1636 " two.txt"
1637 ]
1638 );
1639
1640 let confirm = panel.update_in(cx, |panel, window, cx| {
1641 panel
1642 .filename_editor
1643 .update(cx, |editor, cx| editor.set_text("c", window, cx));
1644 panel.confirm_edit(window, cx).unwrap()
1645 });
1646 assert_eq!(
1647 visible_entries_as_strings(&panel, 0..50, cx),
1648 &[
1649 //
1650 "v root",
1651 " > a",
1652 " > [PROCESSING: 'c'] <== selected",
1653 " v b",
1654 " v a",
1655 " v inner_dir",
1656 " four.txt",
1657 " three.txt",
1658 " one.txt",
1659 " two.txt"
1660 ]
1661 );
1662
1663 confirm.await.unwrap();
1664
1665 panel.update_in(cx, |panel, window, cx| {
1666 panel.paste(&Default::default(), window, cx)
1667 });
1668 cx.executor().run_until_parked();
1669 assert_eq!(
1670 visible_entries_as_strings(&panel, 0..50, cx),
1671 &[
1672 //
1673 "v root",
1674 " > a",
1675 " v b",
1676 " v a",
1677 " v inner_dir",
1678 " four.txt",
1679 " three.txt",
1680 " one.txt",
1681 " two.txt",
1682 " v c",
1683 " > a <== selected",
1684 " > inner_dir",
1685 " one.txt",
1686 " two.txt",
1687 ]
1688 );
1689}
1690
1691#[gpui::test]
1692async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
1693 init_test(cx);
1694
1695 let fs = FakeFs::new(cx.executor());
1696 fs.insert_tree(
1697 "/test",
1698 json!({
1699 "dir1": {
1700 "a.txt": "",
1701 "b.txt": "",
1702 },
1703 "dir2": {},
1704 "c.txt": "",
1705 "d.txt": "",
1706 }),
1707 )
1708 .await;
1709
1710 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1711 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1712 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1713 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1714
1715 toggle_expand_dir(&panel, "test/dir1", cx);
1716
1717 cx.simulate_modifiers_change(gpui::Modifiers {
1718 control: true,
1719 ..Default::default()
1720 });
1721
1722 select_path_with_mark(&panel, "test/dir1", cx);
1723 select_path_with_mark(&panel, "test/c.txt", cx);
1724
1725 assert_eq!(
1726 visible_entries_as_strings(&panel, 0..15, cx),
1727 &[
1728 "v test",
1729 " v dir1 <== marked",
1730 " a.txt",
1731 " b.txt",
1732 " > dir2",
1733 " c.txt <== selected <== marked",
1734 " d.txt",
1735 ],
1736 "Initial state before copying dir1 and c.txt"
1737 );
1738
1739 panel.update_in(cx, |panel, window, cx| {
1740 panel.copy(&Default::default(), window, cx);
1741 });
1742 select_path(&panel, "test/dir2", cx);
1743 panel.update_in(cx, |panel, window, cx| {
1744 panel.paste(&Default::default(), window, cx);
1745 });
1746 cx.executor().run_until_parked();
1747
1748 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1749
1750 assert_eq!(
1751 visible_entries_as_strings(&panel, 0..15, cx),
1752 &[
1753 "v test",
1754 " v dir1 <== marked",
1755 " a.txt",
1756 " b.txt",
1757 " v dir2",
1758 " v dir1 <== selected",
1759 " a.txt",
1760 " b.txt",
1761 " c.txt",
1762 " c.txt <== marked",
1763 " d.txt",
1764 ],
1765 "Should copy dir1 as well as c.txt into dir2"
1766 );
1767
1768 // Disambiguating multiple files should not open the rename editor.
1769 select_path(&panel, "test/dir2", cx);
1770 panel.update_in(cx, |panel, window, cx| {
1771 panel.paste(&Default::default(), window, cx);
1772 });
1773 cx.executor().run_until_parked();
1774
1775 assert_eq!(
1776 visible_entries_as_strings(&panel, 0..15, cx),
1777 &[
1778 "v test",
1779 " v dir1 <== marked",
1780 " a.txt",
1781 " b.txt",
1782 " v dir2",
1783 " v dir1",
1784 " a.txt",
1785 " b.txt",
1786 " > dir1 copy <== selected",
1787 " c.txt",
1788 " c copy.txt",
1789 " c.txt <== marked",
1790 " d.txt",
1791 ],
1792 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
1793 );
1794}
1795
1796#[gpui::test]
1797async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
1798 init_test(cx);
1799
1800 let fs = FakeFs::new(cx.executor());
1801 fs.insert_tree(
1802 "/test",
1803 json!({
1804 "dir1": {
1805 "a.txt": "",
1806 "b.txt": "",
1807 },
1808 "dir2": {},
1809 "c.txt": "",
1810 "d.txt": "",
1811 }),
1812 )
1813 .await;
1814
1815 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1816 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1817 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1818 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1819
1820 toggle_expand_dir(&panel, "test/dir1", cx);
1821
1822 cx.simulate_modifiers_change(gpui::Modifiers {
1823 control: true,
1824 ..Default::default()
1825 });
1826
1827 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
1828 select_path_with_mark(&panel, "test/dir1", cx);
1829 select_path_with_mark(&panel, "test/c.txt", cx);
1830
1831 assert_eq!(
1832 visible_entries_as_strings(&panel, 0..15, cx),
1833 &[
1834 "v test",
1835 " v dir1 <== marked",
1836 " a.txt <== marked",
1837 " b.txt",
1838 " > dir2",
1839 " c.txt <== selected <== marked",
1840 " d.txt",
1841 ],
1842 "Initial state before copying a.txt, dir1 and c.txt"
1843 );
1844
1845 panel.update_in(cx, |panel, window, cx| {
1846 panel.copy(&Default::default(), window, cx);
1847 });
1848 select_path(&panel, "test/dir2", cx);
1849 panel.update_in(cx, |panel, window, cx| {
1850 panel.paste(&Default::default(), window, cx);
1851 });
1852 cx.executor().run_until_parked();
1853
1854 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1855
1856 assert_eq!(
1857 visible_entries_as_strings(&panel, 0..20, cx),
1858 &[
1859 "v test",
1860 " v dir1 <== marked",
1861 " a.txt <== marked",
1862 " b.txt",
1863 " v dir2",
1864 " v dir1 <== selected",
1865 " a.txt",
1866 " b.txt",
1867 " c.txt",
1868 " c.txt <== marked",
1869 " d.txt",
1870 ],
1871 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
1872 );
1873}
1874
1875#[gpui::test]
1876async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1877 init_test_with_editor(cx);
1878
1879 let fs = FakeFs::new(cx.executor());
1880 fs.insert_tree(
1881 path!("/src"),
1882 json!({
1883 "test": {
1884 "first.rs": "// First Rust file",
1885 "second.rs": "// Second Rust file",
1886 "third.rs": "// Third Rust file",
1887 }
1888 }),
1889 )
1890 .await;
1891
1892 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
1893 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1894 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1895 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1896
1897 toggle_expand_dir(&panel, "src/test", cx);
1898 select_path(&panel, "src/test/first.rs", cx);
1899 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1900 cx.executor().run_until_parked();
1901 assert_eq!(
1902 visible_entries_as_strings(&panel, 0..10, cx),
1903 &[
1904 "v src",
1905 " v test",
1906 " first.rs <== selected <== marked",
1907 " second.rs",
1908 " third.rs"
1909 ]
1910 );
1911 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
1912
1913 submit_deletion(&panel, cx);
1914 assert_eq!(
1915 visible_entries_as_strings(&panel, 0..10, cx),
1916 &[
1917 "v src",
1918 " v test",
1919 " second.rs <== selected",
1920 " third.rs"
1921 ],
1922 "Project panel should have no deleted file, no other file is selected in it"
1923 );
1924 ensure_no_open_items_and_panes(&workspace, cx);
1925
1926 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1927 cx.executor().run_until_parked();
1928 assert_eq!(
1929 visible_entries_as_strings(&panel, 0..10, cx),
1930 &[
1931 "v src",
1932 " v test",
1933 " second.rs <== selected <== marked",
1934 " third.rs"
1935 ]
1936 );
1937 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
1938
1939 workspace
1940 .update(cx, |workspace, window, cx| {
1941 let active_items = workspace
1942 .panes()
1943 .iter()
1944 .filter_map(|pane| pane.read(cx).active_item())
1945 .collect::<Vec<_>>();
1946 assert_eq!(active_items.len(), 1);
1947 let open_editor = active_items
1948 .into_iter()
1949 .next()
1950 .unwrap()
1951 .downcast::<Editor>()
1952 .expect("Open item should be an editor");
1953 open_editor.update(cx, |editor, cx| {
1954 editor.set_text("Another text!", window, cx)
1955 });
1956 })
1957 .unwrap();
1958 submit_deletion_skipping_prompt(&panel, cx);
1959 assert_eq!(
1960 visible_entries_as_strings(&panel, 0..10, cx),
1961 &["v src", " v test", " third.rs <== selected"],
1962 "Project panel should have no deleted file, with one last file remaining"
1963 );
1964 ensure_no_open_items_and_panes(&workspace, cx);
1965}
1966
1967#[gpui::test]
1968async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
1969 init_test_with_editor(cx);
1970
1971 let fs = FakeFs::new(cx.executor());
1972 fs.insert_tree(
1973 "/src",
1974 json!({
1975 "test": {
1976 "first.rs": "// First Rust file",
1977 "second.rs": "// Second Rust file",
1978 "third.rs": "// Third Rust file",
1979 }
1980 }),
1981 )
1982 .await;
1983
1984 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
1985 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1986 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1987 let panel = workspace
1988 .update(cx, |workspace, window, cx| {
1989 let panel = ProjectPanel::new(workspace, window, cx);
1990 workspace.add_panel(panel.clone(), window, cx);
1991 panel
1992 })
1993 .unwrap();
1994
1995 select_path(&panel, "src/", cx);
1996 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
1997 cx.executor().run_until_parked();
1998 assert_eq!(
1999 visible_entries_as_strings(&panel, 0..10, cx),
2000 &[
2001 //
2002 "v src <== selected",
2003 " > test"
2004 ]
2005 );
2006 panel.update_in(cx, |panel, window, cx| {
2007 panel.new_directory(&NewDirectory, window, cx)
2008 });
2009 panel.update_in(cx, |panel, window, cx| {
2010 assert!(panel.filename_editor.read(cx).is_focused(window));
2011 });
2012 assert_eq!(
2013 visible_entries_as_strings(&panel, 0..10, cx),
2014 &[
2015 //
2016 "v src",
2017 " > [EDITOR: ''] <== selected",
2018 " > test"
2019 ]
2020 );
2021 panel.update_in(cx, |panel, window, cx| {
2022 panel
2023 .filename_editor
2024 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2025 assert!(
2026 panel.confirm_edit(window, cx).is_none(),
2027 "Should not allow to confirm on conflicting new directory name"
2028 );
2029 });
2030 cx.executor().run_until_parked();
2031 panel.update_in(cx, |panel, window, cx| {
2032 assert!(
2033 panel.edit_state.is_some(),
2034 "Edit state should not be None after conflicting new directory name"
2035 );
2036 panel.cancel(&menu::Cancel, window, cx);
2037 });
2038 assert_eq!(
2039 visible_entries_as_strings(&panel, 0..10, cx),
2040 &[
2041 //
2042 "v src <== selected",
2043 " > test"
2044 ],
2045 "File list should be unchanged after failed folder create confirmation"
2046 );
2047
2048 select_path(&panel, "src/test/", cx);
2049 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2050 cx.executor().run_until_parked();
2051 assert_eq!(
2052 visible_entries_as_strings(&panel, 0..10, cx),
2053 &[
2054 //
2055 "v src",
2056 " > test <== selected"
2057 ]
2058 );
2059 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2060 panel.update_in(cx, |panel, window, cx| {
2061 assert!(panel.filename_editor.read(cx).is_focused(window));
2062 });
2063 assert_eq!(
2064 visible_entries_as_strings(&panel, 0..10, cx),
2065 &[
2066 "v src",
2067 " v test",
2068 " [EDITOR: ''] <== selected",
2069 " first.rs",
2070 " second.rs",
2071 " third.rs"
2072 ]
2073 );
2074 panel.update_in(cx, |panel, window, cx| {
2075 panel
2076 .filename_editor
2077 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2078 assert!(
2079 panel.confirm_edit(window, cx).is_none(),
2080 "Should not allow to confirm on conflicting new file name"
2081 );
2082 });
2083 cx.executor().run_until_parked();
2084 panel.update_in(cx, |panel, window, cx| {
2085 assert!(
2086 panel.edit_state.is_some(),
2087 "Edit state should not be None after conflicting new file name"
2088 );
2089 panel.cancel(&menu::Cancel, window, cx);
2090 });
2091 assert_eq!(
2092 visible_entries_as_strings(&panel, 0..10, cx),
2093 &[
2094 "v src",
2095 " v test <== selected",
2096 " first.rs",
2097 " second.rs",
2098 " third.rs"
2099 ],
2100 "File list should be unchanged after failed file create confirmation"
2101 );
2102
2103 select_path(&panel, "src/test/first.rs", cx);
2104 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2105 cx.executor().run_until_parked();
2106 assert_eq!(
2107 visible_entries_as_strings(&panel, 0..10, cx),
2108 &[
2109 "v src",
2110 " v test",
2111 " first.rs <== selected",
2112 " second.rs",
2113 " third.rs"
2114 ],
2115 );
2116 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2117 panel.update_in(cx, |panel, window, cx| {
2118 assert!(panel.filename_editor.read(cx).is_focused(window));
2119 });
2120 assert_eq!(
2121 visible_entries_as_strings(&panel, 0..10, cx),
2122 &[
2123 "v src",
2124 " v test",
2125 " [EDITOR: 'first.rs'] <== selected",
2126 " second.rs",
2127 " third.rs"
2128 ]
2129 );
2130 panel.update_in(cx, |panel, window, cx| {
2131 panel
2132 .filename_editor
2133 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2134 assert!(
2135 panel.confirm_edit(window, cx).is_none(),
2136 "Should not allow to confirm on conflicting file rename"
2137 )
2138 });
2139 cx.executor().run_until_parked();
2140 panel.update_in(cx, |panel, window, cx| {
2141 assert!(
2142 panel.edit_state.is_some(),
2143 "Edit state should not be None after conflicting file rename"
2144 );
2145 panel.cancel(&menu::Cancel, window, cx);
2146 });
2147 assert_eq!(
2148 visible_entries_as_strings(&panel, 0..10, cx),
2149 &[
2150 "v src",
2151 " v test",
2152 " first.rs <== selected",
2153 " second.rs",
2154 " third.rs"
2155 ],
2156 "File list should be unchanged after failed rename confirmation"
2157 );
2158}
2159
2160#[gpui::test]
2161async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
2162 init_test_with_editor(cx);
2163
2164 let fs = FakeFs::new(cx.executor());
2165 fs.insert_tree(
2166 path!("/root"),
2167 json!({
2168 "tree1": {
2169 ".git": {},
2170 "dir1": {
2171 "modified1.txt": "1",
2172 "unmodified1.txt": "1",
2173 "modified2.txt": "1",
2174 },
2175 "dir2": {
2176 "modified3.txt": "1",
2177 "unmodified2.txt": "1",
2178 },
2179 "modified4.txt": "1",
2180 "unmodified3.txt": "1",
2181 },
2182 "tree2": {
2183 ".git": {},
2184 "dir3": {
2185 "modified5.txt": "1",
2186 "unmodified4.txt": "1",
2187 },
2188 "modified6.txt": "1",
2189 "unmodified5.txt": "1",
2190 }
2191 }),
2192 )
2193 .await;
2194
2195 // Mark files as git modified
2196 fs.set_git_content_for_repo(
2197 path!("/root/tree1/.git").as_ref(),
2198 &[
2199 ("dir1/modified1.txt".into(), "modified".into(), None),
2200 ("dir1/modified2.txt".into(), "modified".into(), None),
2201 ("modified4.txt".into(), "modified".into(), None),
2202 ("dir2/modified3.txt".into(), "modified".into(), None),
2203 ],
2204 );
2205 fs.set_git_content_for_repo(
2206 path!("/root/tree2/.git").as_ref(),
2207 &[
2208 ("dir3/modified5.txt".into(), "modified".into(), None),
2209 ("modified6.txt".into(), "modified".into(), None),
2210 ],
2211 );
2212
2213 let project = Project::test(
2214 fs.clone(),
2215 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2216 cx,
2217 )
2218 .await;
2219
2220 let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
2221 let mut worktrees = project.worktrees(cx);
2222 let worktree1 = worktrees.next().unwrap();
2223 let worktree2 = worktrees.next().unwrap();
2224 (
2225 worktree1.read(cx).as_local().unwrap().scan_complete(),
2226 worktree2.read(cx).as_local().unwrap().scan_complete(),
2227 )
2228 });
2229 scan1_complete.await;
2230 scan2_complete.await;
2231 cx.run_until_parked();
2232
2233 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2234 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2235 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2236
2237 // Check initial state
2238 assert_eq!(
2239 visible_entries_as_strings(&panel, 0..15, cx),
2240 &[
2241 "v tree1",
2242 " > .git",
2243 " > dir1",
2244 " > dir2",
2245 " modified4.txt",
2246 " unmodified3.txt",
2247 "v tree2",
2248 " > .git",
2249 " > dir3",
2250 " modified6.txt",
2251 " unmodified5.txt"
2252 ],
2253 );
2254
2255 // Test selecting next modified entry
2256 panel.update_in(cx, |panel, window, cx| {
2257 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2258 });
2259
2260 assert_eq!(
2261 visible_entries_as_strings(&panel, 0..6, cx),
2262 &[
2263 "v tree1",
2264 " > .git",
2265 " v dir1",
2266 " modified1.txt <== selected",
2267 " modified2.txt",
2268 " unmodified1.txt",
2269 ],
2270 );
2271
2272 panel.update_in(cx, |panel, window, cx| {
2273 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2274 });
2275
2276 assert_eq!(
2277 visible_entries_as_strings(&panel, 0..6, cx),
2278 &[
2279 "v tree1",
2280 " > .git",
2281 " v dir1",
2282 " modified1.txt",
2283 " modified2.txt <== selected",
2284 " unmodified1.txt",
2285 ],
2286 );
2287
2288 panel.update_in(cx, |panel, window, cx| {
2289 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2290 });
2291
2292 assert_eq!(
2293 visible_entries_as_strings(&panel, 6..9, cx),
2294 &[
2295 " v dir2",
2296 " modified3.txt <== selected",
2297 " unmodified2.txt",
2298 ],
2299 );
2300
2301 panel.update_in(cx, |panel, window, cx| {
2302 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2303 });
2304
2305 assert_eq!(
2306 visible_entries_as_strings(&panel, 9..11, cx),
2307 &[" modified4.txt <== selected", " unmodified3.txt",],
2308 );
2309
2310 panel.update_in(cx, |panel, window, cx| {
2311 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2312 });
2313
2314 assert_eq!(
2315 visible_entries_as_strings(&panel, 13..16, cx),
2316 &[
2317 " v dir3",
2318 " modified5.txt <== selected",
2319 " unmodified4.txt",
2320 ],
2321 );
2322
2323 panel.update_in(cx, |panel, window, cx| {
2324 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2325 });
2326
2327 assert_eq!(
2328 visible_entries_as_strings(&panel, 16..18, cx),
2329 &[" modified6.txt <== selected", " unmodified5.txt",],
2330 );
2331
2332 // Wraps around to first modified file
2333 panel.update_in(cx, |panel, window, cx| {
2334 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2335 });
2336
2337 assert_eq!(
2338 visible_entries_as_strings(&panel, 0..18, cx),
2339 &[
2340 "v tree1",
2341 " > .git",
2342 " v dir1",
2343 " modified1.txt <== selected",
2344 " modified2.txt",
2345 " unmodified1.txt",
2346 " v dir2",
2347 " modified3.txt",
2348 " unmodified2.txt",
2349 " modified4.txt",
2350 " unmodified3.txt",
2351 "v tree2",
2352 " > .git",
2353 " v dir3",
2354 " modified5.txt",
2355 " unmodified4.txt",
2356 " modified6.txt",
2357 " unmodified5.txt",
2358 ],
2359 );
2360
2361 // Wraps around again to last modified file
2362 panel.update_in(cx, |panel, window, cx| {
2363 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2364 });
2365
2366 assert_eq!(
2367 visible_entries_as_strings(&panel, 16..18, cx),
2368 &[" modified6.txt <== selected", " unmodified5.txt",],
2369 );
2370
2371 panel.update_in(cx, |panel, window, cx| {
2372 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2373 });
2374
2375 assert_eq!(
2376 visible_entries_as_strings(&panel, 13..16, cx),
2377 &[
2378 " v dir3",
2379 " modified5.txt <== selected",
2380 " unmodified4.txt",
2381 ],
2382 );
2383
2384 panel.update_in(cx, |panel, window, cx| {
2385 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2386 });
2387
2388 assert_eq!(
2389 visible_entries_as_strings(&panel, 9..11, cx),
2390 &[" modified4.txt <== selected", " unmodified3.txt",],
2391 );
2392
2393 panel.update_in(cx, |panel, window, cx| {
2394 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2395 });
2396
2397 assert_eq!(
2398 visible_entries_as_strings(&panel, 6..9, cx),
2399 &[
2400 " v dir2",
2401 " modified3.txt <== selected",
2402 " unmodified2.txt",
2403 ],
2404 );
2405
2406 panel.update_in(cx, |panel, window, cx| {
2407 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2408 });
2409
2410 assert_eq!(
2411 visible_entries_as_strings(&panel, 0..6, cx),
2412 &[
2413 "v tree1",
2414 " > .git",
2415 " v dir1",
2416 " modified1.txt",
2417 " modified2.txt <== selected",
2418 " unmodified1.txt",
2419 ],
2420 );
2421
2422 panel.update_in(cx, |panel, window, cx| {
2423 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
2424 });
2425
2426 assert_eq!(
2427 visible_entries_as_strings(&panel, 0..6, cx),
2428 &[
2429 "v tree1",
2430 " > .git",
2431 " v dir1",
2432 " modified1.txt <== selected",
2433 " modified2.txt",
2434 " unmodified1.txt",
2435 ],
2436 );
2437}
2438
2439#[gpui::test]
2440async fn test_select_directory(cx: &mut gpui::TestAppContext) {
2441 init_test_with_editor(cx);
2442
2443 let fs = FakeFs::new(cx.executor());
2444 fs.insert_tree(
2445 "/project_root",
2446 json!({
2447 "dir_1": {
2448 "nested_dir": {
2449 "file_a.py": "# File contents",
2450 }
2451 },
2452 "file_1.py": "# File contents",
2453 "dir_2": {
2454
2455 },
2456 "dir_3": {
2457
2458 },
2459 "file_2.py": "# File contents",
2460 "dir_4": {
2461
2462 },
2463 }),
2464 )
2465 .await;
2466
2467 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2468 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2469 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2470 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2471
2472 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2473 cx.executor().run_until_parked();
2474 select_path(&panel, "project_root/dir_1", cx);
2475 cx.executor().run_until_parked();
2476 assert_eq!(
2477 visible_entries_as_strings(&panel, 0..10, cx),
2478 &[
2479 "v project_root",
2480 " > dir_1 <== selected",
2481 " > dir_2",
2482 " > dir_3",
2483 " > dir_4",
2484 " file_1.py",
2485 " file_2.py",
2486 ]
2487 );
2488 panel.update_in(cx, |panel, window, cx| {
2489 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2490 });
2491
2492 assert_eq!(
2493 visible_entries_as_strings(&panel, 0..10, cx),
2494 &[
2495 "v project_root <== selected",
2496 " > dir_1",
2497 " > dir_2",
2498 " > dir_3",
2499 " > dir_4",
2500 " file_1.py",
2501 " file_2.py",
2502 ]
2503 );
2504
2505 panel.update_in(cx, |panel, window, cx| {
2506 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
2507 });
2508
2509 assert_eq!(
2510 visible_entries_as_strings(&panel, 0..10, cx),
2511 &[
2512 "v project_root",
2513 " > dir_1",
2514 " > dir_2",
2515 " > dir_3",
2516 " > dir_4 <== selected",
2517 " file_1.py",
2518 " file_2.py",
2519 ]
2520 );
2521
2522 panel.update_in(cx, |panel, window, cx| {
2523 panel.select_next_directory(&SelectNextDirectory, window, cx)
2524 });
2525
2526 assert_eq!(
2527 visible_entries_as_strings(&panel, 0..10, cx),
2528 &[
2529 "v project_root <== selected",
2530 " > dir_1",
2531 " > dir_2",
2532 " > dir_3",
2533 " > dir_4",
2534 " file_1.py",
2535 " file_2.py",
2536 ]
2537 );
2538}
2539
2540#[gpui::test]
2541async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
2542 init_test_with_editor(cx);
2543
2544 let fs = FakeFs::new(cx.executor());
2545 fs.insert_tree(
2546 "/project_root",
2547 json!({
2548 "dir_1": {
2549 "nested_dir": {
2550 "file_a.py": "# File contents",
2551 }
2552 },
2553 "file_1.py": "# File contents",
2554 "file_2.py": "# File contents",
2555 "zdir_2": {
2556 "nested_dir2": {
2557 "file_b.py": "# File contents",
2558 }
2559 },
2560 }),
2561 )
2562 .await;
2563
2564 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2565 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2566 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2567 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2568
2569 assert_eq!(
2570 visible_entries_as_strings(&panel, 0..10, cx),
2571 &[
2572 "v project_root",
2573 " > dir_1",
2574 " > zdir_2",
2575 " file_1.py",
2576 " file_2.py",
2577 ]
2578 );
2579 panel.update_in(cx, |panel, window, cx| {
2580 panel.select_first(&SelectFirst, window, cx)
2581 });
2582
2583 assert_eq!(
2584 visible_entries_as_strings(&panel, 0..10, cx),
2585 &[
2586 "v project_root <== selected",
2587 " > dir_1",
2588 " > zdir_2",
2589 " file_1.py",
2590 " file_2.py",
2591 ]
2592 );
2593
2594 panel.update_in(cx, |panel, window, cx| {
2595 panel.select_last(&SelectLast, window, cx)
2596 });
2597
2598 assert_eq!(
2599 visible_entries_as_strings(&panel, 0..10, cx),
2600 &[
2601 "v project_root",
2602 " > dir_1",
2603 " > zdir_2",
2604 " file_1.py",
2605 " file_2.py <== selected",
2606 ]
2607 );
2608
2609 cx.update(|_, cx| {
2610 let settings = *ProjectPanelSettings::get_global(cx);
2611 ProjectPanelSettings::override_global(
2612 ProjectPanelSettings {
2613 hide_root: true,
2614 ..settings
2615 },
2616 cx,
2617 );
2618 });
2619
2620 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2621
2622 #[rustfmt::skip]
2623 assert_eq!(
2624 visible_entries_as_strings(&panel, 0..10, cx),
2625 &[
2626 "> dir_1",
2627 "> zdir_2",
2628 " file_1.py",
2629 " file_2.py",
2630 ],
2631 "With hide_root=true, root should be hidden"
2632 );
2633
2634 panel.update_in(cx, |panel, window, cx| {
2635 panel.select_first(&SelectFirst, window, cx)
2636 });
2637
2638 assert_eq!(
2639 visible_entries_as_strings(&panel, 0..10, cx),
2640 &[
2641 "> dir_1 <== selected",
2642 "> zdir_2",
2643 " file_1.py",
2644 " file_2.py",
2645 ],
2646 "With hide_root=true, first entry should be dir_1, not the hidden root"
2647 );
2648}
2649
2650#[gpui::test]
2651async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2652 init_test_with_editor(cx);
2653
2654 let fs = FakeFs::new(cx.executor());
2655 fs.insert_tree(
2656 "/project_root",
2657 json!({
2658 "dir_1": {
2659 "nested_dir": {
2660 "file_a.py": "# File contents",
2661 }
2662 },
2663 "file_1.py": "# File contents",
2664 }),
2665 )
2666 .await;
2667
2668 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2669 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2670 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2671 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2672
2673 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2674 cx.executor().run_until_parked();
2675 select_path(&panel, "project_root/dir_1", cx);
2676 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2677 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2678 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2679 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2680 cx.executor().run_until_parked();
2681 assert_eq!(
2682 visible_entries_as_strings(&panel, 0..10, cx),
2683 &[
2684 "v project_root",
2685 " v dir_1",
2686 " > nested_dir <== selected",
2687 " file_1.py",
2688 ]
2689 );
2690}
2691
2692#[gpui::test]
2693async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2694 init_test_with_editor(cx);
2695
2696 let fs = FakeFs::new(cx.executor());
2697 fs.insert_tree(
2698 "/project_root",
2699 json!({
2700 "dir_1": {
2701 "nested_dir": {
2702 "file_a.py": "# File contents",
2703 "file_b.py": "# File contents",
2704 "file_c.py": "# File contents",
2705 },
2706 "file_1.py": "# File contents",
2707 "file_2.py": "# File contents",
2708 "file_3.py": "# File contents",
2709 },
2710 "dir_2": {
2711 "file_1.py": "# File contents",
2712 "file_2.py": "# File contents",
2713 "file_3.py": "# File contents",
2714 }
2715 }),
2716 )
2717 .await;
2718
2719 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2720 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2721 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2722 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2723
2724 panel.update_in(cx, |panel, window, cx| {
2725 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2726 });
2727 cx.executor().run_until_parked();
2728 assert_eq!(
2729 visible_entries_as_strings(&panel, 0..10, cx),
2730 &["v project_root", " > dir_1", " > dir_2",]
2731 );
2732
2733 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2734 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2735 cx.executor().run_until_parked();
2736 assert_eq!(
2737 visible_entries_as_strings(&panel, 0..10, cx),
2738 &[
2739 "v project_root",
2740 " v dir_1 <== selected",
2741 " > nested_dir",
2742 " file_1.py",
2743 " file_2.py",
2744 " file_3.py",
2745 " > dir_2",
2746 ]
2747 );
2748}
2749
2750#[gpui::test]
2751async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
2752 init_test_with_editor(cx);
2753
2754 let fs = FakeFs::new(cx.executor());
2755 let worktree_content = json!({
2756 "dir_1": {
2757 "file_1.py": "# File contents",
2758 },
2759 "dir_2": {
2760 "file_1.py": "# File contents",
2761 }
2762 });
2763
2764 fs.insert_tree("/project_root_1", worktree_content.clone())
2765 .await;
2766 fs.insert_tree("/project_root_2", worktree_content).await;
2767
2768 let project = Project::test(
2769 fs.clone(),
2770 ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
2771 cx,
2772 )
2773 .await;
2774 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2775 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2776 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2777
2778 panel.update_in(cx, |panel, window, cx| {
2779 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2780 });
2781 cx.executor().run_until_parked();
2782 assert_eq!(
2783 visible_entries_as_strings(&panel, 0..10, cx),
2784 &["> project_root_1", "> project_root_2",]
2785 );
2786}
2787
2788#[gpui::test]
2789async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
2790 init_test_with_editor(cx);
2791
2792 let fs = FakeFs::new(cx.executor());
2793 fs.insert_tree(
2794 "/project_root",
2795 json!({
2796 "dir_1": {
2797 "nested_dir": {
2798 "file_a.py": "# File contents",
2799 "file_b.py": "# File contents",
2800 "file_c.py": "# File contents",
2801 },
2802 "file_1.py": "# File contents",
2803 "file_2.py": "# File contents",
2804 "file_3.py": "# File contents",
2805 },
2806 "dir_2": {
2807 "file_1.py": "# File contents",
2808 "file_2.py": "# File contents",
2809 "file_3.py": "# File contents",
2810 }
2811 }),
2812 )
2813 .await;
2814
2815 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2816 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2817 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2818 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2819
2820 // Open project_root/dir_1 to ensure that a nested directory is expanded
2821 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2822 cx.executor().run_until_parked();
2823 assert_eq!(
2824 visible_entries_as_strings(&panel, 0..10, cx),
2825 &[
2826 "v project_root",
2827 " v dir_1 <== selected",
2828 " > nested_dir",
2829 " file_1.py",
2830 " file_2.py",
2831 " file_3.py",
2832 " > dir_2",
2833 ]
2834 );
2835
2836 // Close root directory
2837 toggle_expand_dir(&panel, "project_root", cx);
2838 cx.executor().run_until_parked();
2839 assert_eq!(
2840 visible_entries_as_strings(&panel, 0..10, cx),
2841 &["> project_root <== selected"]
2842 );
2843
2844 // Run collapse_all_entries and make sure root is not expanded
2845 panel.update_in(cx, |panel, window, cx| {
2846 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
2847 });
2848 cx.executor().run_until_parked();
2849 assert_eq!(
2850 visible_entries_as_strings(&panel, 0..10, cx),
2851 &["> project_root <== selected"]
2852 );
2853}
2854
2855#[gpui::test]
2856async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2857 init_test(cx);
2858
2859 let fs = FakeFs::new(cx.executor());
2860 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
2861 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
2862 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2863 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2864 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2865
2866 // Make a new buffer with no backing file
2867 workspace
2868 .update(cx, |workspace, window, cx| {
2869 Editor::new_file(workspace, &Default::default(), window, cx)
2870 })
2871 .unwrap();
2872
2873 cx.executor().run_until_parked();
2874
2875 // "Save as" the buffer, creating a new backing file for it
2876 let save_task = workspace
2877 .update(cx, |workspace, window, cx| {
2878 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2879 })
2880 .unwrap();
2881
2882 cx.executor().run_until_parked();
2883 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
2884 save_task.await.unwrap();
2885
2886 // Rename the file
2887 select_path(&panel, "root/new", cx);
2888 assert_eq!(
2889 visible_entries_as_strings(&panel, 0..10, cx),
2890 &["v root", " new <== selected <== marked"]
2891 );
2892 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2893 panel.update_in(cx, |panel, window, cx| {
2894 panel
2895 .filename_editor
2896 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
2897 });
2898 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2899
2900 cx.executor().run_until_parked();
2901 assert_eq!(
2902 visible_entries_as_strings(&panel, 0..10, cx),
2903 &["v root", " newer <== selected"]
2904 );
2905
2906 workspace
2907 .update(cx, |workspace, window, cx| {
2908 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2909 })
2910 .unwrap()
2911 .await
2912 .unwrap();
2913
2914 cx.executor().run_until_parked();
2915 // assert that saving the file doesn't restore "new"
2916 assert_eq!(
2917 visible_entries_as_strings(&panel, 0..10, cx),
2918 &["v root", " newer <== selected"]
2919 );
2920}
2921
2922#[gpui::test]
2923#[cfg_attr(target_os = "windows", ignore)]
2924async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2925 init_test_with_editor(cx);
2926
2927 let fs = FakeFs::new(cx.executor());
2928 fs.insert_tree(
2929 "/root1",
2930 json!({
2931 "dir1": {
2932 "file1.txt": "content 1",
2933 },
2934 }),
2935 )
2936 .await;
2937
2938 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2939 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2940 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2941 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2942
2943 toggle_expand_dir(&panel, "root1/dir1", cx);
2944
2945 assert_eq!(
2946 visible_entries_as_strings(&panel, 0..20, cx),
2947 &["v root1", " v dir1 <== selected", " file1.txt",],
2948 "Initial state with worktrees"
2949 );
2950
2951 select_path(&panel, "root1", cx);
2952 assert_eq!(
2953 visible_entries_as_strings(&panel, 0..20, cx),
2954 &["v root1 <== selected", " v dir1", " file1.txt",],
2955 );
2956
2957 // Rename root1 to new_root1
2958 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2959
2960 assert_eq!(
2961 visible_entries_as_strings(&panel, 0..20, cx),
2962 &[
2963 "v [EDITOR: 'root1'] <== selected",
2964 " v dir1",
2965 " file1.txt",
2966 ],
2967 );
2968
2969 let confirm = panel.update_in(cx, |panel, window, cx| {
2970 panel
2971 .filename_editor
2972 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
2973 panel.confirm_edit(window, cx).unwrap()
2974 });
2975 confirm.await.unwrap();
2976 assert_eq!(
2977 visible_entries_as_strings(&panel, 0..20, cx),
2978 &[
2979 "v new_root1 <== selected",
2980 " v dir1",
2981 " file1.txt",
2982 ],
2983 "Should update worktree name"
2984 );
2985
2986 // Ensure internal paths have been updated
2987 select_path(&panel, "new_root1/dir1/file1.txt", cx);
2988 assert_eq!(
2989 visible_entries_as_strings(&panel, 0..20, cx),
2990 &[
2991 "v new_root1",
2992 " v dir1",
2993 " file1.txt <== selected",
2994 ],
2995 "Files in renamed worktree are selectable"
2996 );
2997}
2998
2999#[gpui::test]
3000async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3001 init_test_with_editor(cx);
3002
3003 let fs = FakeFs::new(cx.executor());
3004 fs.insert_tree(
3005 "/root1",
3006 json!({
3007 "dir1": { "file1.txt": "content" },
3008 "file2.txt": "content",
3009 }),
3010 )
3011 .await;
3012 fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3013 .await;
3014
3015 // Test 1: Single worktree, hide_root=true - rename should be blocked
3016 {
3017 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3018 let workspace =
3019 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3020 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3021
3022 cx.update(|_, cx| {
3023 let settings = *ProjectPanelSettings::get_global(cx);
3024 ProjectPanelSettings::override_global(
3025 ProjectPanelSettings {
3026 hide_root: true,
3027 ..settings
3028 },
3029 cx,
3030 );
3031 });
3032
3033 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3034
3035 panel.update(cx, |panel, cx| {
3036 let project = panel.project.read(cx);
3037 let worktree = project.visible_worktrees(cx).next().unwrap();
3038 let root_entry = worktree.read(cx).root_entry().unwrap();
3039 panel.selection = Some(SelectedEntry {
3040 worktree_id: worktree.read(cx).id(),
3041 entry_id: root_entry.id,
3042 });
3043 });
3044
3045 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3046
3047 assert!(
3048 panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
3049 "Rename should be blocked when hide_root=true with single worktree"
3050 );
3051 }
3052
3053 // Test 2: Multiple worktrees, hide_root=true - rename should work
3054 {
3055 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3056 let workspace =
3057 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3058 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3059
3060 cx.update(|_, cx| {
3061 let settings = *ProjectPanelSettings::get_global(cx);
3062 ProjectPanelSettings::override_global(
3063 ProjectPanelSettings {
3064 hide_root: true,
3065 ..settings
3066 },
3067 cx,
3068 );
3069 });
3070
3071 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3072 select_path(&panel, "root1", cx);
3073 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3074
3075 #[cfg(target_os = "windows")]
3076 assert!(
3077 panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
3078 "Rename should be blocked on Windows even with multiple worktrees"
3079 );
3080
3081 #[cfg(not(target_os = "windows"))]
3082 {
3083 assert!(
3084 panel.read_with(cx, |panel, _| panel.edit_state.is_some()),
3085 "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3086 );
3087 panel.update_in(cx, |panel, window, cx| {
3088 panel.cancel(&menu::Cancel, window, cx)
3089 });
3090 }
3091 }
3092}
3093
3094#[gpui::test]
3095async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3096 init_test_with_editor(cx);
3097 let fs = FakeFs::new(cx.executor());
3098 fs.insert_tree(
3099 "/project_root",
3100 json!({
3101 "dir_1": {
3102 "nested_dir": {
3103 "file_a.py": "# File contents",
3104 }
3105 },
3106 "file_1.py": "# File contents",
3107 }),
3108 )
3109 .await;
3110
3111 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3112 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3113 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3114 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3115 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3116 cx.update(|window, cx| {
3117 panel.update(cx, |this, cx| {
3118 this.select_next(&Default::default(), window, cx);
3119 this.expand_selected_entry(&Default::default(), window, cx);
3120 this.expand_selected_entry(&Default::default(), window, cx);
3121 this.select_next(&Default::default(), window, cx);
3122 this.expand_selected_entry(&Default::default(), window, cx);
3123 this.select_next(&Default::default(), window, cx);
3124 })
3125 });
3126 assert_eq!(
3127 visible_entries_as_strings(&panel, 0..10, cx),
3128 &[
3129 "v project_root",
3130 " v dir_1",
3131 " v nested_dir",
3132 " file_a.py <== selected",
3133 " file_1.py",
3134 ]
3135 );
3136 let modifiers_with_shift = gpui::Modifiers {
3137 shift: true,
3138 ..Default::default()
3139 };
3140 cx.run_until_parked();
3141 cx.simulate_modifiers_change(modifiers_with_shift);
3142 cx.update(|window, cx| {
3143 panel.update(cx, |this, cx| {
3144 this.select_next(&Default::default(), window, cx);
3145 })
3146 });
3147 assert_eq!(
3148 visible_entries_as_strings(&panel, 0..10, cx),
3149 &[
3150 "v project_root",
3151 " v dir_1",
3152 " v nested_dir",
3153 " file_a.py",
3154 " file_1.py <== selected <== marked",
3155 ]
3156 );
3157 cx.update(|window, cx| {
3158 panel.update(cx, |this, cx| {
3159 this.select_previous(&Default::default(), window, cx);
3160 })
3161 });
3162 assert_eq!(
3163 visible_entries_as_strings(&panel, 0..10, cx),
3164 &[
3165 "v project_root",
3166 " v dir_1",
3167 " v nested_dir",
3168 " file_a.py <== selected <== marked",
3169 " file_1.py <== marked",
3170 ]
3171 );
3172 cx.update(|window, cx| {
3173 panel.update(cx, |this, cx| {
3174 let drag = DraggedSelection {
3175 active_selection: this.selection.unwrap(),
3176 marked_selections: this.marked_entries.clone().into(),
3177 };
3178 let target_entry = this
3179 .project
3180 .read(cx)
3181 .entry_for_path(&(worktree_id, "").into(), cx)
3182 .unwrap();
3183 this.drag_onto(&drag, target_entry.id, false, window, cx);
3184 });
3185 });
3186 cx.run_until_parked();
3187 assert_eq!(
3188 visible_entries_as_strings(&panel, 0..10, cx),
3189 &[
3190 "v project_root",
3191 " v dir_1",
3192 " v nested_dir",
3193 " file_1.py <== marked",
3194 " file_a.py <== selected <== marked",
3195 ]
3196 );
3197 // ESC clears out all marks
3198 cx.update(|window, cx| {
3199 panel.update(cx, |this, cx| {
3200 this.cancel(&menu::Cancel, window, cx);
3201 })
3202 });
3203 assert_eq!(
3204 visible_entries_as_strings(&panel, 0..10, cx),
3205 &[
3206 "v project_root",
3207 " v dir_1",
3208 " v nested_dir",
3209 " file_1.py",
3210 " file_a.py <== selected",
3211 ]
3212 );
3213 // ESC clears out all marks
3214 cx.update(|window, cx| {
3215 panel.update(cx, |this, cx| {
3216 this.select_previous(&SelectPrevious, window, cx);
3217 this.select_next(&SelectNext, window, cx);
3218 })
3219 });
3220 assert_eq!(
3221 visible_entries_as_strings(&panel, 0..10, cx),
3222 &[
3223 "v project_root",
3224 " v dir_1",
3225 " v nested_dir",
3226 " file_1.py <== marked",
3227 " file_a.py <== selected <== marked",
3228 ]
3229 );
3230 cx.simulate_modifiers_change(Default::default());
3231 cx.update(|window, cx| {
3232 panel.update(cx, |this, cx| {
3233 this.cut(&Cut, window, cx);
3234 this.select_previous(&SelectPrevious, window, cx);
3235 this.select_previous(&SelectPrevious, window, cx);
3236
3237 this.paste(&Paste, window, cx);
3238 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
3239 })
3240 });
3241 cx.run_until_parked();
3242 assert_eq!(
3243 visible_entries_as_strings(&panel, 0..10, cx),
3244 &[
3245 "v project_root",
3246 " v dir_1",
3247 " v nested_dir",
3248 " file_1.py <== marked",
3249 " file_a.py <== selected <== marked",
3250 ]
3251 );
3252 cx.simulate_modifiers_change(modifiers_with_shift);
3253 cx.update(|window, cx| {
3254 panel.update(cx, |this, cx| {
3255 this.expand_selected_entry(&Default::default(), window, cx);
3256 this.select_next(&SelectNext, window, cx);
3257 this.select_next(&SelectNext, window, cx);
3258 })
3259 });
3260 submit_deletion(&panel, cx);
3261 assert_eq!(
3262 visible_entries_as_strings(&panel, 0..10, cx),
3263 &[
3264 "v project_root",
3265 " v dir_1",
3266 " v nested_dir <== selected",
3267 ]
3268 );
3269}
3270
3271#[gpui::test]
3272async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
3273 init_test(cx);
3274
3275 let fs = FakeFs::new(cx.executor());
3276 fs.insert_tree(
3277 "/root",
3278 json!({
3279 "a": {
3280 "b": {
3281 "c": {
3282 "d": {}
3283 }
3284 }
3285 },
3286 "target_destination": {}
3287 }),
3288 )
3289 .await;
3290
3291 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3292 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3293 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3294
3295 cx.update(|_, cx| {
3296 let settings = *ProjectPanelSettings::get_global(cx);
3297 ProjectPanelSettings::override_global(
3298 ProjectPanelSettings {
3299 auto_fold_dirs: true,
3300 ..settings
3301 },
3302 cx,
3303 );
3304 });
3305
3306 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3307
3308 // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
3309 select_path(&panel, "root/a/b/c/d", cx);
3310 panel.update_in(cx, |panel, window, cx| {
3311 let drag = DraggedSelection {
3312 active_selection: SelectedEntry {
3313 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3314 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3315 },
3316 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3317 };
3318 let target_entry = panel
3319 .project
3320 .read(cx)
3321 .visible_worktrees(cx)
3322 .next()
3323 .unwrap()
3324 .read(cx)
3325 .entry_for_path("target_destination")
3326 .unwrap();
3327 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3328 });
3329 cx.executor().run_until_parked();
3330
3331 assert_eq!(
3332 visible_entries_as_strings(&panel, 0..10, cx),
3333 &[
3334 "v root",
3335 " > a/b/c",
3336 " > target_destination/d <== selected"
3337 ],
3338 "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
3339 );
3340
3341 // Reset
3342 select_path(&panel, "root/target_destination/d", cx);
3343 panel.update_in(cx, |panel, window, cx| {
3344 let drag = DraggedSelection {
3345 active_selection: SelectedEntry {
3346 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3347 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3348 },
3349 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3350 };
3351 let target_entry = panel
3352 .project
3353 .read(cx)
3354 .visible_worktrees(cx)
3355 .next()
3356 .unwrap()
3357 .read(cx)
3358 .entry_for_path("a/b/c")
3359 .unwrap();
3360 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3361 });
3362 cx.executor().run_until_parked();
3363
3364 // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
3365 select_path(&panel, "root/a/b", cx);
3366 panel.update_in(cx, |panel, window, cx| {
3367 let drag = DraggedSelection {
3368 active_selection: SelectedEntry {
3369 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3370 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3371 },
3372 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3373 };
3374 let target_entry = panel
3375 .project
3376 .read(cx)
3377 .visible_worktrees(cx)
3378 .next()
3379 .unwrap()
3380 .read(cx)
3381 .entry_for_path("target_destination")
3382 .unwrap();
3383 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3384 });
3385 cx.executor().run_until_parked();
3386
3387 assert_eq!(
3388 visible_entries_as_strings(&panel, 0..10, cx),
3389 &["v root", " v a", " > target_destination/b/c/d"],
3390 "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
3391 );
3392
3393 // Reset
3394 select_path(&panel, "root/target_destination/b", cx);
3395 panel.update_in(cx, |panel, window, cx| {
3396 let drag = DraggedSelection {
3397 active_selection: SelectedEntry {
3398 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3399 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3400 },
3401 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3402 };
3403 let target_entry = panel
3404 .project
3405 .read(cx)
3406 .visible_worktrees(cx)
3407 .next()
3408 .unwrap()
3409 .read(cx)
3410 .entry_for_path("a")
3411 .unwrap();
3412 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3413 });
3414 cx.executor().run_until_parked();
3415
3416 // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
3417 select_path(&panel, "root/a", cx);
3418 panel.update_in(cx, |panel, window, cx| {
3419 let drag = DraggedSelection {
3420 active_selection: SelectedEntry {
3421 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3422 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3423 },
3424 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3425 };
3426 let target_entry = panel
3427 .project
3428 .read(cx)
3429 .visible_worktrees(cx)
3430 .next()
3431 .unwrap()
3432 .read(cx)
3433 .entry_for_path("target_destination")
3434 .unwrap();
3435 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3436 });
3437 cx.executor().run_until_parked();
3438
3439 assert_eq!(
3440 visible_entries_as_strings(&panel, 0..10, cx),
3441 &["v root", " > target_destination/a/b/c/d"],
3442 "Moving first directory 'a' should move whole 'a/b/c/d' chain"
3443 );
3444}
3445
3446#[gpui::test]
3447async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3448 init_test_with_editor(cx);
3449 cx.update(|cx| {
3450 cx.update_global::<SettingsStore, _>(|store, cx| {
3451 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3452 worktree_settings.file_scan_exclusions = Some(Vec::new());
3453 });
3454 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3455 project_panel_settings.auto_reveal_entries = Some(false)
3456 });
3457 })
3458 });
3459
3460 let fs = FakeFs::new(cx.background_executor.clone());
3461 fs.insert_tree(
3462 "/project_root",
3463 json!({
3464 ".git": {},
3465 ".gitignore": "**/gitignored_dir",
3466 "dir_1": {
3467 "file_1.py": "# File 1_1 contents",
3468 "file_2.py": "# File 1_2 contents",
3469 "file_3.py": "# File 1_3 contents",
3470 "gitignored_dir": {
3471 "file_a.py": "# File contents",
3472 "file_b.py": "# File contents",
3473 "file_c.py": "# File contents",
3474 },
3475 },
3476 "dir_2": {
3477 "file_1.py": "# File 2_1 contents",
3478 "file_2.py": "# File 2_2 contents",
3479 "file_3.py": "# File 2_3 contents",
3480 }
3481 }),
3482 )
3483 .await;
3484
3485 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3486 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3487 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3488 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3489
3490 assert_eq!(
3491 visible_entries_as_strings(&panel, 0..20, cx),
3492 &[
3493 "v project_root",
3494 " > .git",
3495 " > dir_1",
3496 " > dir_2",
3497 " .gitignore",
3498 ]
3499 );
3500
3501 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3502 .expect("dir 1 file is not ignored and should have an entry");
3503 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3504 .expect("dir 2 file is not ignored and should have an entry");
3505 let gitignored_dir_file =
3506 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3507 assert_eq!(
3508 gitignored_dir_file, None,
3509 "File in the gitignored dir should not have an entry before its dir is toggled"
3510 );
3511
3512 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3513 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3514 cx.executor().run_until_parked();
3515 assert_eq!(
3516 visible_entries_as_strings(&panel, 0..20, cx),
3517 &[
3518 "v project_root",
3519 " > .git",
3520 " v dir_1",
3521 " v gitignored_dir <== selected",
3522 " file_a.py",
3523 " file_b.py",
3524 " file_c.py",
3525 " file_1.py",
3526 " file_2.py",
3527 " file_3.py",
3528 " > dir_2",
3529 " .gitignore",
3530 ],
3531 "Should show gitignored dir file list in the project panel"
3532 );
3533 let gitignored_dir_file =
3534 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3535 .expect("after gitignored dir got opened, a file entry should be present");
3536
3537 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3538 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3539 assert_eq!(
3540 visible_entries_as_strings(&panel, 0..20, cx),
3541 &[
3542 "v project_root",
3543 " > .git",
3544 " > dir_1 <== selected",
3545 " > dir_2",
3546 " .gitignore",
3547 ],
3548 "Should hide all dir contents again and prepare for the auto reveal test"
3549 );
3550
3551 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3552 panel.update(cx, |panel, cx| {
3553 panel.project.update(cx, |_, cx| {
3554 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3555 })
3556 });
3557 cx.run_until_parked();
3558 assert_eq!(
3559 visible_entries_as_strings(&panel, 0..20, cx),
3560 &[
3561 "v project_root",
3562 " > .git",
3563 " > dir_1 <== selected",
3564 " > dir_2",
3565 " .gitignore",
3566 ],
3567 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3568 );
3569 }
3570
3571 cx.update(|_, cx| {
3572 cx.update_global::<SettingsStore, _>(|store, cx| {
3573 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3574 project_panel_settings.auto_reveal_entries = Some(true)
3575 });
3576 })
3577 });
3578
3579 panel.update(cx, |panel, cx| {
3580 panel.project.update(cx, |_, cx| {
3581 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3582 })
3583 });
3584 cx.run_until_parked();
3585 assert_eq!(
3586 visible_entries_as_strings(&panel, 0..20, cx),
3587 &[
3588 "v project_root",
3589 " > .git",
3590 " v dir_1",
3591 " > gitignored_dir",
3592 " file_1.py <== selected <== marked",
3593 " file_2.py",
3594 " file_3.py",
3595 " > dir_2",
3596 " .gitignore",
3597 ],
3598 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3599 );
3600
3601 panel.update(cx, |panel, cx| {
3602 panel.project.update(cx, |_, cx| {
3603 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3604 })
3605 });
3606 cx.run_until_parked();
3607 assert_eq!(
3608 visible_entries_as_strings(&panel, 0..20, cx),
3609 &[
3610 "v project_root",
3611 " > .git",
3612 " v dir_1",
3613 " > gitignored_dir",
3614 " file_1.py",
3615 " file_2.py",
3616 " file_3.py",
3617 " v dir_2",
3618 " file_1.py <== selected <== marked",
3619 " file_2.py",
3620 " file_3.py",
3621 " .gitignore",
3622 ],
3623 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3624 );
3625
3626 panel.update(cx, |panel, cx| {
3627 panel.project.update(cx, |_, cx| {
3628 cx.emit(project::Event::ActiveEntryChanged(Some(
3629 gitignored_dir_file,
3630 )))
3631 })
3632 });
3633 cx.run_until_parked();
3634 assert_eq!(
3635 visible_entries_as_strings(&panel, 0..20, cx),
3636 &[
3637 "v project_root",
3638 " > .git",
3639 " v dir_1",
3640 " > gitignored_dir",
3641 " file_1.py",
3642 " file_2.py",
3643 " file_3.py",
3644 " v dir_2",
3645 " file_1.py <== selected <== marked",
3646 " file_2.py",
3647 " file_3.py",
3648 " .gitignore",
3649 ],
3650 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3651 );
3652
3653 panel.update(cx, |panel, cx| {
3654 panel.project.update(cx, |_, cx| {
3655 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3656 })
3657 });
3658 cx.run_until_parked();
3659 assert_eq!(
3660 visible_entries_as_strings(&panel, 0..20, cx),
3661 &[
3662 "v project_root",
3663 " > .git",
3664 " v dir_1",
3665 " v gitignored_dir",
3666 " file_a.py <== selected <== marked",
3667 " file_b.py",
3668 " file_c.py",
3669 " file_1.py",
3670 " file_2.py",
3671 " file_3.py",
3672 " v dir_2",
3673 " file_1.py",
3674 " file_2.py",
3675 " file_3.py",
3676 " .gitignore",
3677 ],
3678 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3679 );
3680}
3681
3682#[gpui::test]
3683async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3684 init_test_with_editor(cx);
3685 cx.update(|cx| {
3686 cx.update_global::<SettingsStore, _>(|store, cx| {
3687 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3688 worktree_settings.file_scan_exclusions = Some(Vec::new());
3689 worktree_settings.file_scan_inclusions =
3690 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3691 });
3692 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3693 project_panel_settings.auto_reveal_entries = Some(false)
3694 });
3695 })
3696 });
3697
3698 let fs = FakeFs::new(cx.background_executor.clone());
3699 fs.insert_tree(
3700 "/project_root",
3701 json!({
3702 ".git": {},
3703 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3704 "dir_1": {
3705 "file_1.py": "# File 1_1 contents",
3706 "file_2.py": "# File 1_2 contents",
3707 "file_3.py": "# File 1_3 contents",
3708 "gitignored_dir": {
3709 "file_a.py": "# File contents",
3710 "file_b.py": "# File contents",
3711 "file_c.py": "# File contents",
3712 },
3713 },
3714 "dir_2": {
3715 "file_1.py": "# File 2_1 contents",
3716 "file_2.py": "# File 2_2 contents",
3717 "file_3.py": "# File 2_3 contents",
3718 },
3719 "always_included_but_ignored_dir": {
3720 "file_a.py": "# File contents",
3721 "file_b.py": "# File contents",
3722 "file_c.py": "# File contents",
3723 },
3724 }),
3725 )
3726 .await;
3727
3728 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3729 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3730 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3731 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3732
3733 assert_eq!(
3734 visible_entries_as_strings(&panel, 0..20, cx),
3735 &[
3736 "v project_root",
3737 " > .git",
3738 " > always_included_but_ignored_dir",
3739 " > dir_1",
3740 " > dir_2",
3741 " .gitignore",
3742 ]
3743 );
3744
3745 let gitignored_dir_file =
3746 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3747 let always_included_but_ignored_dir_file = find_project_entry(
3748 &panel,
3749 "project_root/always_included_but_ignored_dir/file_a.py",
3750 cx,
3751 )
3752 .expect("file that is .gitignored but set to always be included should have an entry");
3753 assert_eq!(
3754 gitignored_dir_file, None,
3755 "File in the gitignored dir should not have an entry unless its directory is toggled"
3756 );
3757
3758 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3759 cx.run_until_parked();
3760 cx.update(|_, cx| {
3761 cx.update_global::<SettingsStore, _>(|store, cx| {
3762 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3763 project_panel_settings.auto_reveal_entries = Some(true)
3764 });
3765 })
3766 });
3767
3768 panel.update(cx, |panel, cx| {
3769 panel.project.update(cx, |_, cx| {
3770 cx.emit(project::Event::ActiveEntryChanged(Some(
3771 always_included_but_ignored_dir_file,
3772 )))
3773 })
3774 });
3775 cx.run_until_parked();
3776
3777 assert_eq!(
3778 visible_entries_as_strings(&panel, 0..20, cx),
3779 &[
3780 "v project_root",
3781 " > .git",
3782 " v always_included_but_ignored_dir",
3783 " file_a.py <== selected <== marked",
3784 " file_b.py",
3785 " file_c.py",
3786 " v dir_1",
3787 " > gitignored_dir",
3788 " file_1.py",
3789 " file_2.py",
3790 " file_3.py",
3791 " > dir_2",
3792 " .gitignore",
3793 ],
3794 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3795 );
3796}
3797
3798#[gpui::test]
3799async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3800 init_test_with_editor(cx);
3801 cx.update(|cx| {
3802 cx.update_global::<SettingsStore, _>(|store, cx| {
3803 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3804 worktree_settings.file_scan_exclusions = Some(Vec::new());
3805 });
3806 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3807 project_panel_settings.auto_reveal_entries = Some(false)
3808 });
3809 })
3810 });
3811
3812 let fs = FakeFs::new(cx.background_executor.clone());
3813 fs.insert_tree(
3814 "/project_root",
3815 json!({
3816 ".git": {},
3817 ".gitignore": "**/gitignored_dir",
3818 "dir_1": {
3819 "file_1.py": "# File 1_1 contents",
3820 "file_2.py": "# File 1_2 contents",
3821 "file_3.py": "# File 1_3 contents",
3822 "gitignored_dir": {
3823 "file_a.py": "# File contents",
3824 "file_b.py": "# File contents",
3825 "file_c.py": "# File contents",
3826 },
3827 },
3828 "dir_2": {
3829 "file_1.py": "# File 2_1 contents",
3830 "file_2.py": "# File 2_2 contents",
3831 "file_3.py": "# File 2_3 contents",
3832 }
3833 }),
3834 )
3835 .await;
3836
3837 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3838 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3839 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3840 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3841
3842 assert_eq!(
3843 visible_entries_as_strings(&panel, 0..20, cx),
3844 &[
3845 "v project_root",
3846 " > .git",
3847 " > dir_1",
3848 " > dir_2",
3849 " .gitignore",
3850 ]
3851 );
3852
3853 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3854 .expect("dir 1 file is not ignored and should have an entry");
3855 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3856 .expect("dir 2 file is not ignored and should have an entry");
3857 let gitignored_dir_file =
3858 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3859 assert_eq!(
3860 gitignored_dir_file, None,
3861 "File in the gitignored dir should not have an entry before its dir is toggled"
3862 );
3863
3864 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3865 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3866 cx.run_until_parked();
3867 assert_eq!(
3868 visible_entries_as_strings(&panel, 0..20, cx),
3869 &[
3870 "v project_root",
3871 " > .git",
3872 " v dir_1",
3873 " v gitignored_dir <== selected",
3874 " file_a.py",
3875 " file_b.py",
3876 " file_c.py",
3877 " file_1.py",
3878 " file_2.py",
3879 " file_3.py",
3880 " > dir_2",
3881 " .gitignore",
3882 ],
3883 "Should show gitignored dir file list in the project panel"
3884 );
3885 let gitignored_dir_file =
3886 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3887 .expect("after gitignored dir got opened, a file entry should be present");
3888
3889 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3890 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3891 assert_eq!(
3892 visible_entries_as_strings(&panel, 0..20, cx),
3893 &[
3894 "v project_root",
3895 " > .git",
3896 " > dir_1 <== selected",
3897 " > dir_2",
3898 " .gitignore",
3899 ],
3900 "Should hide all dir contents again and prepare for the explicit reveal test"
3901 );
3902
3903 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3904 panel.update(cx, |panel, cx| {
3905 panel.project.update(cx, |_, cx| {
3906 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3907 })
3908 });
3909 cx.run_until_parked();
3910 assert_eq!(
3911 visible_entries_as_strings(&panel, 0..20, cx),
3912 &[
3913 "v project_root",
3914 " > .git",
3915 " > dir_1 <== selected",
3916 " > dir_2",
3917 " .gitignore",
3918 ],
3919 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3920 );
3921 }
3922
3923 panel.update(cx, |panel, cx| {
3924 panel.project.update(cx, |_, cx| {
3925 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3926 })
3927 });
3928 cx.run_until_parked();
3929 assert_eq!(
3930 visible_entries_as_strings(&panel, 0..20, cx),
3931 &[
3932 "v project_root",
3933 " > .git",
3934 " v dir_1",
3935 " > gitignored_dir",
3936 " file_1.py <== selected <== marked",
3937 " file_2.py",
3938 " file_3.py",
3939 " > dir_2",
3940 " .gitignore",
3941 ],
3942 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3943 );
3944
3945 panel.update(cx, |panel, cx| {
3946 panel.project.update(cx, |_, cx| {
3947 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3948 })
3949 });
3950 cx.run_until_parked();
3951 assert_eq!(
3952 visible_entries_as_strings(&panel, 0..20, cx),
3953 &[
3954 "v project_root",
3955 " > .git",
3956 " v dir_1",
3957 " > gitignored_dir",
3958 " file_1.py",
3959 " file_2.py",
3960 " file_3.py",
3961 " v dir_2",
3962 " file_1.py <== selected <== marked",
3963 " file_2.py",
3964 " file_3.py",
3965 " .gitignore",
3966 ],
3967 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3968 );
3969
3970 panel.update(cx, |panel, cx| {
3971 panel.project.update(cx, |_, cx| {
3972 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3973 })
3974 });
3975 cx.run_until_parked();
3976 assert_eq!(
3977 visible_entries_as_strings(&panel, 0..20, cx),
3978 &[
3979 "v project_root",
3980 " > .git",
3981 " v dir_1",
3982 " v gitignored_dir",
3983 " file_a.py <== selected <== marked",
3984 " file_b.py",
3985 " file_c.py",
3986 " file_1.py",
3987 " file_2.py",
3988 " file_3.py",
3989 " v dir_2",
3990 " file_1.py",
3991 " file_2.py",
3992 " file_3.py",
3993 " .gitignore",
3994 ],
3995 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3996 );
3997}
3998
3999#[gpui::test]
4000async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4001 init_test(cx);
4002 cx.update(|cx| {
4003 cx.update_global::<SettingsStore, _>(|store, cx| {
4004 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4005 project_settings.file_scan_exclusions =
4006 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4007 });
4008 });
4009 });
4010
4011 cx.update(|cx| {
4012 register_project_item::<TestProjectItemView>(cx);
4013 });
4014
4015 let fs = FakeFs::new(cx.executor());
4016 fs.insert_tree(
4017 "/root1",
4018 json!({
4019 ".dockerignore": "",
4020 ".git": {
4021 "HEAD": "",
4022 },
4023 }),
4024 )
4025 .await;
4026
4027 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4028 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4029 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4030 let panel = workspace
4031 .update(cx, |workspace, window, cx| {
4032 let panel = ProjectPanel::new(workspace, window, cx);
4033 workspace.add_panel(panel.clone(), window, cx);
4034 panel
4035 })
4036 .unwrap();
4037
4038 select_path(&panel, "root1", cx);
4039 assert_eq!(
4040 visible_entries_as_strings(&panel, 0..10, cx),
4041 &["v root1 <== selected", " .dockerignore",]
4042 );
4043 workspace
4044 .update(cx, |workspace, _, cx| {
4045 assert!(
4046 workspace.active_item(cx).is_none(),
4047 "Should have no active items in the beginning"
4048 );
4049 })
4050 .unwrap();
4051
4052 let excluded_file_path = ".git/COMMIT_EDITMSG";
4053 let excluded_dir_path = "excluded_dir";
4054
4055 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
4056 panel.update_in(cx, |panel, window, cx| {
4057 assert!(panel.filename_editor.read(cx).is_focused(window));
4058 });
4059 panel
4060 .update_in(cx, |panel, window, cx| {
4061 panel.filename_editor.update(cx, |editor, cx| {
4062 editor.set_text(excluded_file_path, window, cx)
4063 });
4064 panel.confirm_edit(window, cx).unwrap()
4065 })
4066 .await
4067 .unwrap();
4068
4069 assert_eq!(
4070 visible_entries_as_strings(&panel, 0..13, cx),
4071 &["v root1", " .dockerignore"],
4072 "Excluded dir should not be shown after opening a file in it"
4073 );
4074 panel.update_in(cx, |panel, window, cx| {
4075 assert!(
4076 !panel.filename_editor.read(cx).is_focused(window),
4077 "Should have closed the file name editor"
4078 );
4079 });
4080 workspace
4081 .update(cx, |workspace, _, cx| {
4082 let active_entry_path = workspace
4083 .active_item(cx)
4084 .expect("should have opened and activated the excluded item")
4085 .act_as::<TestProjectItemView>(cx)
4086 .expect("should have opened the corresponding project item for the excluded item")
4087 .read(cx)
4088 .path
4089 .clone();
4090 assert_eq!(
4091 active_entry_path.path.as_ref(),
4092 Path::new(excluded_file_path),
4093 "Should open the excluded file"
4094 );
4095
4096 assert!(
4097 workspace.notification_ids().is_empty(),
4098 "Should have no notifications after opening an excluded file"
4099 );
4100 })
4101 .unwrap();
4102 assert!(
4103 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4104 "Should have created the excluded file"
4105 );
4106
4107 select_path(&panel, "root1", cx);
4108 panel.update_in(cx, |panel, window, cx| {
4109 panel.new_directory(&NewDirectory, window, cx)
4110 });
4111 panel.update_in(cx, |panel, window, cx| {
4112 assert!(panel.filename_editor.read(cx).is_focused(window));
4113 });
4114 panel
4115 .update_in(cx, |panel, window, cx| {
4116 panel.filename_editor.update(cx, |editor, cx| {
4117 editor.set_text(excluded_file_path, window, cx)
4118 });
4119 panel.confirm_edit(window, cx).unwrap()
4120 })
4121 .await
4122 .unwrap();
4123
4124 assert_eq!(
4125 visible_entries_as_strings(&panel, 0..13, cx),
4126 &["v root1", " .dockerignore"],
4127 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4128 );
4129 panel.update_in(cx, |panel, window, cx| {
4130 assert!(
4131 !panel.filename_editor.read(cx).is_focused(window),
4132 "Should have closed the file name editor"
4133 );
4134 });
4135 workspace
4136 .update(cx, |workspace, _, cx| {
4137 let notifications = workspace.notification_ids();
4138 assert_eq!(
4139 notifications.len(),
4140 1,
4141 "Should receive one notification with the error message"
4142 );
4143 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4144 assert!(workspace.notification_ids().is_empty());
4145 })
4146 .unwrap();
4147
4148 select_path(&panel, "root1", cx);
4149 panel.update_in(cx, |panel, window, cx| {
4150 panel.new_directory(&NewDirectory, window, cx)
4151 });
4152 panel.update_in(cx, |panel, window, cx| {
4153 assert!(panel.filename_editor.read(cx).is_focused(window));
4154 });
4155 panel
4156 .update_in(cx, |panel, window, cx| {
4157 panel.filename_editor.update(cx, |editor, cx| {
4158 editor.set_text(excluded_dir_path, window, cx)
4159 });
4160 panel.confirm_edit(window, cx).unwrap()
4161 })
4162 .await
4163 .unwrap();
4164
4165 assert_eq!(
4166 visible_entries_as_strings(&panel, 0..13, cx),
4167 &["v root1", " .dockerignore"],
4168 "Should not change the project panel after trying to create an excluded directory"
4169 );
4170 panel.update_in(cx, |panel, window, cx| {
4171 assert!(
4172 !panel.filename_editor.read(cx).is_focused(window),
4173 "Should have closed the file name editor"
4174 );
4175 });
4176 workspace
4177 .update(cx, |workspace, _, cx| {
4178 let notifications = workspace.notification_ids();
4179 assert_eq!(
4180 notifications.len(),
4181 1,
4182 "Should receive one notification explaining that no directory is actually shown"
4183 );
4184 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4185 assert!(workspace.notification_ids().is_empty());
4186 })
4187 .unwrap();
4188 assert!(
4189 fs.is_dir(Path::new("/root1/excluded_dir")).await,
4190 "Should have created the excluded directory"
4191 );
4192}
4193
4194#[gpui::test]
4195async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
4196 init_test_with_editor(cx);
4197
4198 let fs = FakeFs::new(cx.executor());
4199 fs.insert_tree(
4200 "/src",
4201 json!({
4202 "test": {
4203 "first.rs": "// First Rust file",
4204 "second.rs": "// Second Rust file",
4205 "third.rs": "// Third Rust file",
4206 }
4207 }),
4208 )
4209 .await;
4210
4211 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4212 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4213 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4214 let panel = workspace
4215 .update(cx, |workspace, window, cx| {
4216 let panel = ProjectPanel::new(workspace, window, cx);
4217 workspace.add_panel(panel.clone(), window, cx);
4218 panel
4219 })
4220 .unwrap();
4221
4222 select_path(&panel, "src/", cx);
4223 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
4224 cx.executor().run_until_parked();
4225 assert_eq!(
4226 visible_entries_as_strings(&panel, 0..10, cx),
4227 &[
4228 //
4229 "v src <== selected",
4230 " > test"
4231 ]
4232 );
4233 panel.update_in(cx, |panel, window, cx| {
4234 panel.new_directory(&NewDirectory, window, cx)
4235 });
4236 panel.update_in(cx, |panel, window, cx| {
4237 assert!(panel.filename_editor.read(cx).is_focused(window));
4238 });
4239 assert_eq!(
4240 visible_entries_as_strings(&panel, 0..10, cx),
4241 &[
4242 //
4243 "v src",
4244 " > [EDITOR: ''] <== selected",
4245 " > test"
4246 ]
4247 );
4248
4249 panel.update_in(cx, |panel, window, cx| {
4250 panel.cancel(&menu::Cancel, window, cx)
4251 });
4252 assert_eq!(
4253 visible_entries_as_strings(&panel, 0..10, cx),
4254 &[
4255 //
4256 "v src <== selected",
4257 " > test"
4258 ]
4259 );
4260}
4261
4262#[gpui::test]
4263async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
4264 init_test_with_editor(cx);
4265
4266 let fs = FakeFs::new(cx.executor());
4267 fs.insert_tree(
4268 "/root",
4269 json!({
4270 "dir1": {
4271 "subdir1": {},
4272 "file1.txt": "",
4273 "file2.txt": "",
4274 },
4275 "dir2": {
4276 "subdir2": {},
4277 "file3.txt": "",
4278 "file4.txt": "",
4279 },
4280 "file5.txt": "",
4281 "file6.txt": "",
4282 }),
4283 )
4284 .await;
4285
4286 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4287 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4288 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4289 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4290
4291 toggle_expand_dir(&panel, "root/dir1", cx);
4292 toggle_expand_dir(&panel, "root/dir2", cx);
4293
4294 // Test Case 1: Delete middle file in directory
4295 select_path(&panel, "root/dir1/file1.txt", cx);
4296 assert_eq!(
4297 visible_entries_as_strings(&panel, 0..15, cx),
4298 &[
4299 "v root",
4300 " v dir1",
4301 " > subdir1",
4302 " file1.txt <== selected",
4303 " file2.txt",
4304 " v dir2",
4305 " > subdir2",
4306 " file3.txt",
4307 " file4.txt",
4308 " file5.txt",
4309 " file6.txt",
4310 ],
4311 "Initial state before deleting middle file"
4312 );
4313
4314 submit_deletion(&panel, cx);
4315 assert_eq!(
4316 visible_entries_as_strings(&panel, 0..15, cx),
4317 &[
4318 "v root",
4319 " v dir1",
4320 " > subdir1",
4321 " file2.txt <== selected",
4322 " v dir2",
4323 " > subdir2",
4324 " file3.txt",
4325 " file4.txt",
4326 " file5.txt",
4327 " file6.txt",
4328 ],
4329 "Should select next file after deleting middle file"
4330 );
4331
4332 // Test Case 2: Delete last file in directory
4333 submit_deletion(&panel, cx);
4334 assert_eq!(
4335 visible_entries_as_strings(&panel, 0..15, cx),
4336 &[
4337 "v root",
4338 " v dir1",
4339 " > subdir1 <== selected",
4340 " v dir2",
4341 " > subdir2",
4342 " file3.txt",
4343 " file4.txt",
4344 " file5.txt",
4345 " file6.txt",
4346 ],
4347 "Should select next directory when last file is deleted"
4348 );
4349
4350 // Test Case 3: Delete root level file
4351 select_path(&panel, "root/file6.txt", cx);
4352 assert_eq!(
4353 visible_entries_as_strings(&panel, 0..15, cx),
4354 &[
4355 "v root",
4356 " v dir1",
4357 " > subdir1",
4358 " v dir2",
4359 " > subdir2",
4360 " file3.txt",
4361 " file4.txt",
4362 " file5.txt",
4363 " file6.txt <== selected",
4364 ],
4365 "Initial state before deleting root level file"
4366 );
4367
4368 submit_deletion(&panel, cx);
4369 assert_eq!(
4370 visible_entries_as_strings(&panel, 0..15, cx),
4371 &[
4372 "v root",
4373 " v dir1",
4374 " > subdir1",
4375 " v dir2",
4376 " > subdir2",
4377 " file3.txt",
4378 " file4.txt",
4379 " file5.txt <== selected",
4380 ],
4381 "Should select prev entry at root level"
4382 );
4383}
4384
4385#[gpui::test]
4386async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
4387 init_test_with_editor(cx);
4388
4389 let fs = FakeFs::new(cx.executor());
4390 fs.insert_tree(
4391 path!("/root"),
4392 json!({
4393 "aa": "// Testing 1",
4394 "bb": "// Testing 2",
4395 "cc": "// Testing 3",
4396 "dd": "// Testing 4",
4397 "ee": "// Testing 5",
4398 "ff": "// Testing 6",
4399 "gg": "// Testing 7",
4400 "hh": "// Testing 8",
4401 "ii": "// Testing 8",
4402 ".gitignore": "bb\ndd\nee\nff\nii\n'",
4403 }),
4404 )
4405 .await;
4406
4407 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4408 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4409 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4410
4411 // Test 1: Auto selection with one gitignored file next to the deleted file
4412 cx.update(|_, cx| {
4413 let settings = *ProjectPanelSettings::get_global(cx);
4414 ProjectPanelSettings::override_global(
4415 ProjectPanelSettings {
4416 hide_gitignore: true,
4417 ..settings
4418 },
4419 cx,
4420 );
4421 });
4422
4423 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4424
4425 select_path(&panel, "root/aa", cx);
4426 assert_eq!(
4427 visible_entries_as_strings(&panel, 0..10, cx),
4428 &[
4429 "v root",
4430 " .gitignore",
4431 " aa <== selected",
4432 " cc",
4433 " gg",
4434 " hh"
4435 ],
4436 "Initial state should hide files on .gitignore"
4437 );
4438
4439 submit_deletion(&panel, cx);
4440
4441 assert_eq!(
4442 visible_entries_as_strings(&panel, 0..10, cx),
4443 &[
4444 "v root",
4445 " .gitignore",
4446 " cc <== selected",
4447 " gg",
4448 " hh"
4449 ],
4450 "Should select next entry not on .gitignore"
4451 );
4452
4453 // Test 2: Auto selection with many gitignored files next to the deleted file
4454 submit_deletion(&panel, cx);
4455 assert_eq!(
4456 visible_entries_as_strings(&panel, 0..10, cx),
4457 &[
4458 "v root",
4459 " .gitignore",
4460 " gg <== selected",
4461 " hh"
4462 ],
4463 "Should select next entry not on .gitignore"
4464 );
4465
4466 // Test 3: Auto selection of entry before deleted file
4467 select_path(&panel, "root/hh", cx);
4468 assert_eq!(
4469 visible_entries_as_strings(&panel, 0..10, cx),
4470 &[
4471 "v root",
4472 " .gitignore",
4473 " gg",
4474 " hh <== selected"
4475 ],
4476 "Should select next entry not on .gitignore"
4477 );
4478 submit_deletion(&panel, cx);
4479 assert_eq!(
4480 visible_entries_as_strings(&panel, 0..10, cx),
4481 &["v root", " .gitignore", " gg <== selected"],
4482 "Should select next entry not on .gitignore"
4483 );
4484}
4485
4486#[gpui::test]
4487async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
4488 init_test_with_editor(cx);
4489
4490 let fs = FakeFs::new(cx.executor());
4491 fs.insert_tree(
4492 path!("/root"),
4493 json!({
4494 "dir1": {
4495 "file1": "// Testing",
4496 "file2": "// Testing",
4497 "file3": "// Testing"
4498 },
4499 "aa": "// Testing",
4500 ".gitignore": "file1\nfile3\n",
4501 }),
4502 )
4503 .await;
4504
4505 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4506 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4507 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4508
4509 cx.update(|_, cx| {
4510 let settings = *ProjectPanelSettings::get_global(cx);
4511 ProjectPanelSettings::override_global(
4512 ProjectPanelSettings {
4513 hide_gitignore: true,
4514 ..settings
4515 },
4516 cx,
4517 );
4518 });
4519
4520 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4521
4522 // Test 1: Visible items should exclude files on gitignore
4523 toggle_expand_dir(&panel, "root/dir1", cx);
4524 select_path(&panel, "root/dir1/file2", cx);
4525 assert_eq!(
4526 visible_entries_as_strings(&panel, 0..10, cx),
4527 &[
4528 "v root",
4529 " v dir1",
4530 " file2 <== selected",
4531 " .gitignore",
4532 " aa"
4533 ],
4534 "Initial state should hide files on .gitignore"
4535 );
4536 submit_deletion(&panel, cx);
4537
4538 // Test 2: Auto selection should go to the parent
4539 assert_eq!(
4540 visible_entries_as_strings(&panel, 0..10, cx),
4541 &[
4542 "v root",
4543 " v dir1 <== selected",
4544 " .gitignore",
4545 " aa"
4546 ],
4547 "Initial state should hide files on .gitignore"
4548 );
4549}
4550
4551#[gpui::test]
4552async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
4553 init_test_with_editor(cx);
4554
4555 let fs = FakeFs::new(cx.executor());
4556 fs.insert_tree(
4557 "/root",
4558 json!({
4559 "dir1": {
4560 "subdir1": {
4561 "a.txt": "",
4562 "b.txt": ""
4563 },
4564 "file1.txt": "",
4565 },
4566 "dir2": {
4567 "subdir2": {
4568 "c.txt": "",
4569 "d.txt": ""
4570 },
4571 "file2.txt": "",
4572 },
4573 "file3.txt": "",
4574 }),
4575 )
4576 .await;
4577
4578 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4579 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4580 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4581 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4582
4583 toggle_expand_dir(&panel, "root/dir1", cx);
4584 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4585 toggle_expand_dir(&panel, "root/dir2", cx);
4586 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4587
4588 // Test Case 1: Select and delete nested directory with parent
4589 cx.simulate_modifiers_change(gpui::Modifiers {
4590 control: true,
4591 ..Default::default()
4592 });
4593 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4594 select_path_with_mark(&panel, "root/dir1", cx);
4595
4596 assert_eq!(
4597 visible_entries_as_strings(&panel, 0..15, cx),
4598 &[
4599 "v root",
4600 " v dir1 <== selected <== marked",
4601 " v subdir1 <== marked",
4602 " a.txt",
4603 " b.txt",
4604 " file1.txt",
4605 " v dir2",
4606 " v subdir2",
4607 " c.txt",
4608 " d.txt",
4609 " file2.txt",
4610 " file3.txt",
4611 ],
4612 "Initial state before deleting nested directory with parent"
4613 );
4614
4615 submit_deletion(&panel, cx);
4616 assert_eq!(
4617 visible_entries_as_strings(&panel, 0..15, cx),
4618 &[
4619 "v root",
4620 " v dir2 <== selected",
4621 " v subdir2",
4622 " c.txt",
4623 " d.txt",
4624 " file2.txt",
4625 " file3.txt",
4626 ],
4627 "Should select next directory after deleting directory with parent"
4628 );
4629
4630 // Test Case 2: Select mixed files and directories across levels
4631 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4632 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4633 select_path_with_mark(&panel, "root/file3.txt", cx);
4634
4635 assert_eq!(
4636 visible_entries_as_strings(&panel, 0..15, cx),
4637 &[
4638 "v root",
4639 " v dir2",
4640 " v subdir2",
4641 " c.txt <== marked",
4642 " d.txt",
4643 " file2.txt <== marked",
4644 " file3.txt <== selected <== marked",
4645 ],
4646 "Initial state before deleting"
4647 );
4648
4649 submit_deletion(&panel, cx);
4650 assert_eq!(
4651 visible_entries_as_strings(&panel, 0..15, cx),
4652 &[
4653 "v root",
4654 " v dir2 <== selected",
4655 " v subdir2",
4656 " d.txt",
4657 ],
4658 "Should select sibling directory"
4659 );
4660}
4661
4662#[gpui::test]
4663async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4664 init_test_with_editor(cx);
4665
4666 let fs = FakeFs::new(cx.executor());
4667 fs.insert_tree(
4668 "/root",
4669 json!({
4670 "dir1": {
4671 "subdir1": {
4672 "a.txt": "",
4673 "b.txt": ""
4674 },
4675 "file1.txt": "",
4676 },
4677 "dir2": {
4678 "subdir2": {
4679 "c.txt": "",
4680 "d.txt": ""
4681 },
4682 "file2.txt": "",
4683 },
4684 "file3.txt": "",
4685 "file4.txt": "",
4686 }),
4687 )
4688 .await;
4689
4690 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4691 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4692 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4693 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4694
4695 toggle_expand_dir(&panel, "root/dir1", cx);
4696 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4697 toggle_expand_dir(&panel, "root/dir2", cx);
4698 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4699
4700 // Test Case 1: Select all root files and directories
4701 cx.simulate_modifiers_change(gpui::Modifiers {
4702 control: true,
4703 ..Default::default()
4704 });
4705 select_path_with_mark(&panel, "root/dir1", cx);
4706 select_path_with_mark(&panel, "root/dir2", cx);
4707 select_path_with_mark(&panel, "root/file3.txt", cx);
4708 select_path_with_mark(&panel, "root/file4.txt", cx);
4709 assert_eq!(
4710 visible_entries_as_strings(&panel, 0..20, cx),
4711 &[
4712 "v root",
4713 " v dir1 <== marked",
4714 " v subdir1",
4715 " a.txt",
4716 " b.txt",
4717 " file1.txt",
4718 " v dir2 <== marked",
4719 " v subdir2",
4720 " c.txt",
4721 " d.txt",
4722 " file2.txt",
4723 " file3.txt <== marked",
4724 " file4.txt <== selected <== marked",
4725 ],
4726 "State before deleting all contents"
4727 );
4728
4729 submit_deletion(&panel, cx);
4730 assert_eq!(
4731 visible_entries_as_strings(&panel, 0..20, cx),
4732 &["v root <== selected"],
4733 "Only empty root directory should remain after deleting all contents"
4734 );
4735}
4736
4737#[gpui::test]
4738async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4739 init_test_with_editor(cx);
4740
4741 let fs = FakeFs::new(cx.executor());
4742 fs.insert_tree(
4743 "/root",
4744 json!({
4745 "dir1": {
4746 "subdir1": {
4747 "file_a.txt": "content a",
4748 "file_b.txt": "content b",
4749 },
4750 "subdir2": {
4751 "file_c.txt": "content c",
4752 },
4753 "file1.txt": "content 1",
4754 },
4755 "dir2": {
4756 "file2.txt": "content 2",
4757 },
4758 }),
4759 )
4760 .await;
4761
4762 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4763 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4764 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4765 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4766
4767 toggle_expand_dir(&panel, "root/dir1", cx);
4768 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4769 toggle_expand_dir(&panel, "root/dir2", cx);
4770 cx.simulate_modifiers_change(gpui::Modifiers {
4771 control: true,
4772 ..Default::default()
4773 });
4774
4775 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4776 select_path_with_mark(&panel, "root/dir1", cx);
4777 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4778 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4779
4780 assert_eq!(
4781 visible_entries_as_strings(&panel, 0..20, cx),
4782 &[
4783 "v root",
4784 " v dir1 <== marked",
4785 " v subdir1 <== marked",
4786 " file_a.txt <== selected <== marked",
4787 " file_b.txt",
4788 " > subdir2",
4789 " file1.txt",
4790 " v dir2",
4791 " file2.txt",
4792 ],
4793 "State with parent dir, subdir, and file selected"
4794 );
4795 submit_deletion(&panel, cx);
4796 assert_eq!(
4797 visible_entries_as_strings(&panel, 0..20, cx),
4798 &["v root", " v dir2 <== selected", " file2.txt",],
4799 "Only dir2 should remain after deletion"
4800 );
4801}
4802
4803#[gpui::test]
4804async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4805 init_test_with_editor(cx);
4806
4807 let fs = FakeFs::new(cx.executor());
4808 // First worktree
4809 fs.insert_tree(
4810 "/root1",
4811 json!({
4812 "dir1": {
4813 "file1.txt": "content 1",
4814 "file2.txt": "content 2",
4815 },
4816 "dir2": {
4817 "file3.txt": "content 3",
4818 },
4819 }),
4820 )
4821 .await;
4822
4823 // Second worktree
4824 fs.insert_tree(
4825 "/root2",
4826 json!({
4827 "dir3": {
4828 "file4.txt": "content 4",
4829 "file5.txt": "content 5",
4830 },
4831 "file6.txt": "content 6",
4832 }),
4833 )
4834 .await;
4835
4836 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4837 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4838 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4839 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4840
4841 // Expand all directories for testing
4842 toggle_expand_dir(&panel, "root1/dir1", cx);
4843 toggle_expand_dir(&panel, "root1/dir2", cx);
4844 toggle_expand_dir(&panel, "root2/dir3", cx);
4845
4846 // Test Case 1: Delete files across different worktrees
4847 cx.simulate_modifiers_change(gpui::Modifiers {
4848 control: true,
4849 ..Default::default()
4850 });
4851 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4852 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4853
4854 assert_eq!(
4855 visible_entries_as_strings(&panel, 0..20, cx),
4856 &[
4857 "v root1",
4858 " v dir1",
4859 " file1.txt <== marked",
4860 " file2.txt",
4861 " v dir2",
4862 " file3.txt",
4863 "v root2",
4864 " v dir3",
4865 " file4.txt <== selected <== marked",
4866 " file5.txt",
4867 " file6.txt",
4868 ],
4869 "Initial state with files selected from different worktrees"
4870 );
4871
4872 submit_deletion(&panel, cx);
4873 assert_eq!(
4874 visible_entries_as_strings(&panel, 0..20, cx),
4875 &[
4876 "v root1",
4877 " v dir1",
4878 " file2.txt",
4879 " v dir2",
4880 " file3.txt",
4881 "v root2",
4882 " v dir3",
4883 " file5.txt <== selected",
4884 " file6.txt",
4885 ],
4886 "Should select next file in the last worktree after deletion"
4887 );
4888
4889 // Test Case 2: Delete directories from different worktrees
4890 select_path_with_mark(&panel, "root1/dir1", cx);
4891 select_path_with_mark(&panel, "root2/dir3", cx);
4892
4893 assert_eq!(
4894 visible_entries_as_strings(&panel, 0..20, cx),
4895 &[
4896 "v root1",
4897 " v dir1 <== marked",
4898 " file2.txt",
4899 " v dir2",
4900 " file3.txt",
4901 "v root2",
4902 " v dir3 <== selected <== marked",
4903 " file5.txt",
4904 " file6.txt",
4905 ],
4906 "State with directories marked from different worktrees"
4907 );
4908
4909 submit_deletion(&panel, cx);
4910 assert_eq!(
4911 visible_entries_as_strings(&panel, 0..20, cx),
4912 &[
4913 "v root1",
4914 " v dir2",
4915 " file3.txt",
4916 "v root2",
4917 " file6.txt <== selected",
4918 ],
4919 "Should select remaining file in last worktree after directory deletion"
4920 );
4921
4922 // Test Case 4: Delete all remaining files except roots
4923 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4924 select_path_with_mark(&panel, "root2/file6.txt", cx);
4925
4926 assert_eq!(
4927 visible_entries_as_strings(&panel, 0..20, cx),
4928 &[
4929 "v root1",
4930 " v dir2",
4931 " file3.txt <== marked",
4932 "v root2",
4933 " file6.txt <== selected <== marked",
4934 ],
4935 "State with all remaining files marked"
4936 );
4937
4938 submit_deletion(&panel, cx);
4939 assert_eq!(
4940 visible_entries_as_strings(&panel, 0..20, cx),
4941 &["v root1", " v dir2", "v root2 <== selected"],
4942 "Second parent root should be selected after deleting"
4943 );
4944}
4945
4946#[gpui::test]
4947async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4948 init_test_with_editor(cx);
4949
4950 let fs = FakeFs::new(cx.executor());
4951 fs.insert_tree(
4952 "/root",
4953 json!({
4954 "dir1": {
4955 "file1.txt": "",
4956 "file2.txt": "",
4957 "file3.txt": "",
4958 },
4959 "dir2": {
4960 "file4.txt": "",
4961 "file5.txt": "",
4962 },
4963 }),
4964 )
4965 .await;
4966
4967 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4968 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4969 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4970 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4971
4972 toggle_expand_dir(&panel, "root/dir1", cx);
4973 toggle_expand_dir(&panel, "root/dir2", cx);
4974
4975 cx.simulate_modifiers_change(gpui::Modifiers {
4976 control: true,
4977 ..Default::default()
4978 });
4979
4980 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4981 select_path(&panel, "root/dir1/file1.txt", cx);
4982
4983 assert_eq!(
4984 visible_entries_as_strings(&panel, 0..15, cx),
4985 &[
4986 "v root",
4987 " v dir1",
4988 " file1.txt <== selected",
4989 " file2.txt <== marked",
4990 " file3.txt",
4991 " v dir2",
4992 " file4.txt",
4993 " file5.txt",
4994 ],
4995 "Initial state with one marked entry and different selection"
4996 );
4997
4998 // Delete should operate on the selected entry (file1.txt)
4999 submit_deletion(&panel, cx);
5000 assert_eq!(
5001 visible_entries_as_strings(&panel, 0..15, cx),
5002 &[
5003 "v root",
5004 " v dir1",
5005 " file2.txt <== selected <== marked",
5006 " file3.txt",
5007 " v dir2",
5008 " file4.txt",
5009 " file5.txt",
5010 ],
5011 "Should delete selected file, not marked file"
5012 );
5013
5014 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
5015 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
5016 select_path(&panel, "root/dir2/file5.txt", cx);
5017
5018 assert_eq!(
5019 visible_entries_as_strings(&panel, 0..15, cx),
5020 &[
5021 "v root",
5022 " v dir1",
5023 " file2.txt <== marked",
5024 " file3.txt <== marked",
5025 " v dir2",
5026 " file4.txt <== marked",
5027 " file5.txt <== selected",
5028 ],
5029 "Initial state with multiple marked entries and different selection"
5030 );
5031
5032 // Delete should operate on all marked entries, ignoring the selection
5033 submit_deletion(&panel, cx);
5034 assert_eq!(
5035 visible_entries_as_strings(&panel, 0..15, cx),
5036 &[
5037 "v root",
5038 " v dir1",
5039 " v dir2",
5040 " file5.txt <== selected",
5041 ],
5042 "Should delete all marked files, leaving only the selected file"
5043 );
5044}
5045
5046#[gpui::test]
5047async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
5048 init_test_with_editor(cx);
5049
5050 let fs = FakeFs::new(cx.executor());
5051 fs.insert_tree(
5052 "/root_b",
5053 json!({
5054 "dir1": {
5055 "file1.txt": "content 1",
5056 "file2.txt": "content 2",
5057 },
5058 }),
5059 )
5060 .await;
5061
5062 fs.insert_tree(
5063 "/root_c",
5064 json!({
5065 "dir2": {},
5066 }),
5067 )
5068 .await;
5069
5070 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
5071 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5072 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5073 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5074
5075 toggle_expand_dir(&panel, "root_b/dir1", cx);
5076 toggle_expand_dir(&panel, "root_c/dir2", cx);
5077
5078 cx.simulate_modifiers_change(gpui::Modifiers {
5079 control: true,
5080 ..Default::default()
5081 });
5082 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
5083 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
5084
5085 assert_eq!(
5086 visible_entries_as_strings(&panel, 0..20, cx),
5087 &[
5088 "v root_b",
5089 " v dir1",
5090 " file1.txt <== marked",
5091 " file2.txt <== selected <== marked",
5092 "v root_c",
5093 " v dir2",
5094 ],
5095 "Initial state with files marked in root_b"
5096 );
5097
5098 submit_deletion(&panel, cx);
5099 assert_eq!(
5100 visible_entries_as_strings(&panel, 0..20, cx),
5101 &[
5102 "v root_b",
5103 " v dir1 <== selected",
5104 "v root_c",
5105 " v dir2",
5106 ],
5107 "After deletion in root_b as it's last deletion, selection should be in root_b"
5108 );
5109
5110 select_path_with_mark(&panel, "root_c/dir2", cx);
5111
5112 submit_deletion(&panel, cx);
5113 assert_eq!(
5114 visible_entries_as_strings(&panel, 0..20, cx),
5115 &["v root_b", " v dir1", "v root_c <== selected",],
5116 "After deleting from root_c, it should remain in root_c"
5117 );
5118}
5119
5120fn toggle_expand_dir(
5121 panel: &Entity<ProjectPanel>,
5122 path: impl AsRef<Path>,
5123 cx: &mut VisualTestContext,
5124) {
5125 let path = path.as_ref();
5126 panel.update_in(cx, |panel, window, cx| {
5127 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5128 let worktree = worktree.read(cx);
5129 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5130 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5131 panel.toggle_expanded(entry_id, window, cx);
5132 return;
5133 }
5134 }
5135 panic!("no worktree for path {:?}", path);
5136 });
5137}
5138
5139#[gpui::test]
5140async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
5141 init_test_with_editor(cx);
5142
5143 let fs = FakeFs::new(cx.executor());
5144 fs.insert_tree(
5145 path!("/root"),
5146 json!({
5147 ".gitignore": "**/ignored_dir\n**/ignored_nested",
5148 "dir1": {
5149 "empty1": {
5150 "empty2": {
5151 "empty3": {
5152 "file.txt": ""
5153 }
5154 }
5155 },
5156 "subdir1": {
5157 "file1.txt": "",
5158 "file2.txt": "",
5159 "ignored_nested": {
5160 "ignored_file.txt": ""
5161 }
5162 },
5163 "ignored_dir": {
5164 "subdir": {
5165 "deep_file.txt": ""
5166 }
5167 }
5168 }
5169 }),
5170 )
5171 .await;
5172
5173 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5174 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5175 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5176
5177 // Test 1: When auto-fold is enabled
5178 cx.update(|_, cx| {
5179 let settings = *ProjectPanelSettings::get_global(cx);
5180 ProjectPanelSettings::override_global(
5181 ProjectPanelSettings {
5182 auto_fold_dirs: true,
5183 ..settings
5184 },
5185 cx,
5186 );
5187 });
5188
5189 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5190
5191 assert_eq!(
5192 visible_entries_as_strings(&panel, 0..20, cx),
5193 &["v root", " > dir1", " .gitignore",],
5194 "Initial state should show collapsed root structure"
5195 );
5196
5197 toggle_expand_dir(&panel, "root/dir1", cx);
5198 assert_eq!(
5199 visible_entries_as_strings(&panel, 0..20, cx),
5200 &[
5201 "v root",
5202 " v dir1 <== selected",
5203 " > empty1/empty2/empty3",
5204 " > ignored_dir",
5205 " > subdir1",
5206 " .gitignore",
5207 ],
5208 "Should show first level with auto-folded dirs and ignored dir visible"
5209 );
5210
5211 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5212 panel.update(cx, |panel, cx| {
5213 let project = panel.project.read(cx);
5214 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5215 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5216 panel.update_visible_entries(None, cx);
5217 });
5218 cx.run_until_parked();
5219
5220 assert_eq!(
5221 visible_entries_as_strings(&panel, 0..20, cx),
5222 &[
5223 "v root",
5224 " v dir1 <== selected",
5225 " v empty1",
5226 " v empty2",
5227 " v empty3",
5228 " file.txt",
5229 " > ignored_dir",
5230 " v subdir1",
5231 " > ignored_nested",
5232 " file1.txt",
5233 " file2.txt",
5234 " .gitignore",
5235 ],
5236 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
5237 );
5238
5239 // Test 2: When auto-fold is disabled
5240 cx.update(|_, cx| {
5241 let settings = *ProjectPanelSettings::get_global(cx);
5242 ProjectPanelSettings::override_global(
5243 ProjectPanelSettings {
5244 auto_fold_dirs: false,
5245 ..settings
5246 },
5247 cx,
5248 );
5249 });
5250
5251 panel.update_in(cx, |panel, window, cx| {
5252 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
5253 });
5254
5255 toggle_expand_dir(&panel, "root/dir1", cx);
5256 assert_eq!(
5257 visible_entries_as_strings(&panel, 0..20, cx),
5258 &[
5259 "v root",
5260 " v dir1 <== selected",
5261 " > empty1",
5262 " > ignored_dir",
5263 " > subdir1",
5264 " .gitignore",
5265 ],
5266 "With auto-fold disabled: should show all directories separately"
5267 );
5268
5269 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5270 panel.update(cx, |panel, cx| {
5271 let project = panel.project.read(cx);
5272 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5273 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5274 panel.update_visible_entries(None, cx);
5275 });
5276 cx.run_until_parked();
5277
5278 assert_eq!(
5279 visible_entries_as_strings(&panel, 0..20, cx),
5280 &[
5281 "v root",
5282 " v dir1 <== selected",
5283 " v empty1",
5284 " v empty2",
5285 " v empty3",
5286 " file.txt",
5287 " > ignored_dir",
5288 " v subdir1",
5289 " > ignored_nested",
5290 " file1.txt",
5291 " file2.txt",
5292 " .gitignore",
5293 ],
5294 "After expand_all without auto-fold: should expand all dirs normally, \
5295 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
5296 );
5297
5298 // Test 3: When explicitly called on ignored directory
5299 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
5300 panel.update(cx, |panel, cx| {
5301 let project = panel.project.read(cx);
5302 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5303 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
5304 panel.update_visible_entries(None, cx);
5305 });
5306 cx.run_until_parked();
5307
5308 assert_eq!(
5309 visible_entries_as_strings(&panel, 0..20, cx),
5310 &[
5311 "v root",
5312 " v dir1 <== selected",
5313 " v empty1",
5314 " v empty2",
5315 " v empty3",
5316 " file.txt",
5317 " v ignored_dir",
5318 " v subdir",
5319 " deep_file.txt",
5320 " v subdir1",
5321 " > ignored_nested",
5322 " file1.txt",
5323 " file2.txt",
5324 " .gitignore",
5325 ],
5326 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
5327 );
5328}
5329
5330#[gpui::test]
5331async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
5332 init_test(cx);
5333
5334 let fs = FakeFs::new(cx.executor());
5335 fs.insert_tree(
5336 path!("/root"),
5337 json!({
5338 "dir1": {
5339 "subdir1": {
5340 "nested1": {
5341 "file1.txt": "",
5342 "file2.txt": ""
5343 },
5344 },
5345 "subdir2": {
5346 "file4.txt": ""
5347 }
5348 },
5349 "dir2": {
5350 "single_file": {
5351 "file5.txt": ""
5352 }
5353 }
5354 }),
5355 )
5356 .await;
5357
5358 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5359 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5360 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5361
5362 // Test 1: Basic collapsing
5363 {
5364 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5365
5366 toggle_expand_dir(&panel, "root/dir1", cx);
5367 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5368 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5369 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
5370
5371 assert_eq!(
5372 visible_entries_as_strings(&panel, 0..20, cx),
5373 &[
5374 "v root",
5375 " v dir1",
5376 " v subdir1",
5377 " v nested1",
5378 " file1.txt",
5379 " file2.txt",
5380 " v subdir2 <== selected",
5381 " file4.txt",
5382 " > dir2",
5383 ],
5384 "Initial state with everything expanded"
5385 );
5386
5387 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5388 panel.update(cx, |panel, cx| {
5389 let project = panel.project.read(cx);
5390 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5391 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5392 panel.update_visible_entries(None, cx);
5393 });
5394
5395 assert_eq!(
5396 visible_entries_as_strings(&panel, 0..20, cx),
5397 &["v root", " > dir1", " > dir2",],
5398 "All subdirs under dir1 should be collapsed"
5399 );
5400 }
5401
5402 // Test 2: With auto-fold enabled
5403 {
5404 cx.update(|_, cx| {
5405 let settings = *ProjectPanelSettings::get_global(cx);
5406 ProjectPanelSettings::override_global(
5407 ProjectPanelSettings {
5408 auto_fold_dirs: true,
5409 ..settings
5410 },
5411 cx,
5412 );
5413 });
5414
5415 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5416
5417 toggle_expand_dir(&panel, "root/dir1", cx);
5418 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5419 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5420
5421 assert_eq!(
5422 visible_entries_as_strings(&panel, 0..20, cx),
5423 &[
5424 "v root",
5425 " v dir1",
5426 " v subdir1/nested1 <== selected",
5427 " file1.txt",
5428 " file2.txt",
5429 " > subdir2",
5430 " > dir2/single_file",
5431 ],
5432 "Initial state with some dirs expanded"
5433 );
5434
5435 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5436 panel.update(cx, |panel, cx| {
5437 let project = panel.project.read(cx);
5438 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5439 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5440 });
5441
5442 toggle_expand_dir(&panel, "root/dir1", cx);
5443
5444 assert_eq!(
5445 visible_entries_as_strings(&panel, 0..20, cx),
5446 &[
5447 "v root",
5448 " v dir1 <== selected",
5449 " > subdir1/nested1",
5450 " > subdir2",
5451 " > dir2/single_file",
5452 ],
5453 "Subdirs should be collapsed and folded with auto-fold enabled"
5454 );
5455 }
5456
5457 // Test 3: With auto-fold disabled
5458 {
5459 cx.update(|_, cx| {
5460 let settings = *ProjectPanelSettings::get_global(cx);
5461 ProjectPanelSettings::override_global(
5462 ProjectPanelSettings {
5463 auto_fold_dirs: false,
5464 ..settings
5465 },
5466 cx,
5467 );
5468 });
5469
5470 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5471
5472 toggle_expand_dir(&panel, "root/dir1", cx);
5473 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5474 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5475
5476 assert_eq!(
5477 visible_entries_as_strings(&panel, 0..20, cx),
5478 &[
5479 "v root",
5480 " v dir1",
5481 " v subdir1",
5482 " v nested1 <== selected",
5483 " file1.txt",
5484 " file2.txt",
5485 " > subdir2",
5486 " > dir2",
5487 ],
5488 "Initial state with some dirs expanded and auto-fold disabled"
5489 );
5490
5491 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5492 panel.update(cx, |panel, cx| {
5493 let project = panel.project.read(cx);
5494 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5495 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5496 });
5497
5498 toggle_expand_dir(&panel, "root/dir1", cx);
5499
5500 assert_eq!(
5501 visible_entries_as_strings(&panel, 0..20, cx),
5502 &[
5503 "v root",
5504 " v dir1 <== selected",
5505 " > subdir1",
5506 " > subdir2",
5507 " > dir2",
5508 ],
5509 "Subdirs should be collapsed but not folded with auto-fold disabled"
5510 );
5511 }
5512}
5513
5514#[gpui::test]
5515async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
5516 init_test(cx);
5517
5518 let fs = FakeFs::new(cx.executor());
5519 fs.insert_tree(
5520 path!("/root"),
5521 json!({
5522 "dir1": {
5523 "file1.txt": "",
5524 },
5525 }),
5526 )
5527 .await;
5528
5529 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5530 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5531 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5532
5533 let panel = workspace
5534 .update(cx, |workspace, window, cx| {
5535 let panel = ProjectPanel::new(workspace, window, cx);
5536 workspace.add_panel(panel.clone(), window, cx);
5537 panel
5538 })
5539 .unwrap();
5540
5541 #[rustfmt::skip]
5542 assert_eq!(
5543 visible_entries_as_strings(&panel, 0..20, cx),
5544 &[
5545 "v root",
5546 " > dir1",
5547 ],
5548 "Initial state with nothing selected"
5549 );
5550
5551 panel.update_in(cx, |panel, window, cx| {
5552 panel.new_file(&NewFile, window, cx);
5553 });
5554 panel.update_in(cx, |panel, window, cx| {
5555 assert!(panel.filename_editor.read(cx).is_focused(window));
5556 });
5557 panel
5558 .update_in(cx, |panel, window, cx| {
5559 panel.filename_editor.update(cx, |editor, cx| {
5560 editor.set_text("hello_from_no_selections", window, cx)
5561 });
5562 panel.confirm_edit(window, cx).unwrap()
5563 })
5564 .await
5565 .unwrap();
5566
5567 #[rustfmt::skip]
5568 assert_eq!(
5569 visible_entries_as_strings(&panel, 0..20, cx),
5570 &[
5571 "v root",
5572 " > dir1",
5573 " hello_from_no_selections <== selected <== marked",
5574 ],
5575 "A new file is created under the root directory"
5576 );
5577}
5578
5579#[gpui::test]
5580async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
5581 init_test(cx);
5582
5583 let fs = FakeFs::new(cx.executor());
5584 fs.insert_tree(
5585 path!("/root"),
5586 json!({
5587 "existing_dir": {
5588 "existing_file.txt": "",
5589 },
5590 "existing_file.txt": "",
5591 }),
5592 )
5593 .await;
5594
5595 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5596 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5597 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5598
5599 cx.update(|_, cx| {
5600 let settings = *ProjectPanelSettings::get_global(cx);
5601 ProjectPanelSettings::override_global(
5602 ProjectPanelSettings {
5603 hide_root: true,
5604 ..settings
5605 },
5606 cx,
5607 );
5608 });
5609
5610 let panel = workspace
5611 .update(cx, |workspace, window, cx| {
5612 let panel = ProjectPanel::new(workspace, window, cx);
5613 workspace.add_panel(panel.clone(), window, cx);
5614 panel
5615 })
5616 .unwrap();
5617
5618 #[rustfmt::skip]
5619 assert_eq!(
5620 visible_entries_as_strings(&panel, 0..20, cx),
5621 &[
5622 "> existing_dir",
5623 " existing_file.txt",
5624 ],
5625 "Initial state with hide_root=true, root should be hidden and nothing selected"
5626 );
5627
5628 panel.update(cx, |panel, _| {
5629 assert!(
5630 panel.selection.is_none(),
5631 "Should have no selection initially"
5632 );
5633 });
5634
5635 // Test 1: Create new file when no entry is selected
5636 panel.update_in(cx, |panel, window, cx| {
5637 panel.new_file(&NewFile, window, cx);
5638 });
5639 panel.update_in(cx, |panel, window, cx| {
5640 assert!(panel.filename_editor.read(cx).is_focused(window));
5641 });
5642
5643 #[rustfmt::skip]
5644 assert_eq!(
5645 visible_entries_as_strings(&panel, 0..20, cx),
5646 &[
5647 "> existing_dir",
5648 " [EDITOR: ''] <== selected",
5649 " existing_file.txt",
5650 ],
5651 "Editor should appear at root level when hide_root=true and no selection"
5652 );
5653
5654 let confirm = panel.update_in(cx, |panel, window, cx| {
5655 panel.filename_editor.update(cx, |editor, cx| {
5656 editor.set_text("new_file_at_root.txt", window, cx)
5657 });
5658 panel.confirm_edit(window, cx).unwrap()
5659 });
5660 confirm.await.unwrap();
5661
5662 #[rustfmt::skip]
5663 assert_eq!(
5664 visible_entries_as_strings(&panel, 0..20, cx),
5665 &[
5666 "> existing_dir",
5667 " existing_file.txt",
5668 " new_file_at_root.txt <== selected <== marked",
5669 ],
5670 "New file should be created at root level and visible without root prefix"
5671 );
5672
5673 assert!(
5674 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
5675 "File should be created in the actual root directory"
5676 );
5677
5678 // Test 2: Create new directory when no entry is selected
5679 panel.update(cx, |panel, _| {
5680 panel.selection = None;
5681 });
5682
5683 panel.update_in(cx, |panel, window, cx| {
5684 panel.new_directory(&NewDirectory, window, cx);
5685 });
5686 panel.update_in(cx, |panel, window, cx| {
5687 assert!(panel.filename_editor.read(cx).is_focused(window));
5688 });
5689
5690 #[rustfmt::skip]
5691 assert_eq!(
5692 visible_entries_as_strings(&panel, 0..20, cx),
5693 &[
5694 "> [EDITOR: ''] <== selected",
5695 "> existing_dir",
5696 " existing_file.txt",
5697 " new_file_at_root.txt",
5698 ],
5699 "Directory editor should appear at root level when hide_root=true and no selection"
5700 );
5701
5702 let confirm = panel.update_in(cx, |panel, window, cx| {
5703 panel.filename_editor.update(cx, |editor, cx| {
5704 editor.set_text("new_dir_at_root", window, cx)
5705 });
5706 panel.confirm_edit(window, cx).unwrap()
5707 });
5708 confirm.await.unwrap();
5709
5710 #[rustfmt::skip]
5711 assert_eq!(
5712 visible_entries_as_strings(&panel, 0..20, cx),
5713 &[
5714 "> existing_dir",
5715 "v new_dir_at_root <== selected",
5716 " existing_file.txt",
5717 " new_file_at_root.txt",
5718 ],
5719 "New directory should be created at root level and visible without root prefix"
5720 );
5721
5722 assert!(
5723 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
5724 "Directory should be created in the actual root directory"
5725 );
5726}
5727
5728#[gpui::test]
5729async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
5730 init_test(cx);
5731
5732 let fs = FakeFs::new(cx.executor());
5733 fs.insert_tree(
5734 "/root",
5735 json!({
5736 "dir1": {
5737 "file1.txt": "",
5738 "dir2": {
5739 "file2.txt": ""
5740 }
5741 },
5742 "file3.txt": ""
5743 }),
5744 )
5745 .await;
5746
5747 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5748 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5749 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5750 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5751
5752 panel.update(cx, |panel, cx| {
5753 let project = panel.project.read(cx);
5754 let worktree = project.visible_worktrees(cx).next().unwrap();
5755 let worktree = worktree.read(cx);
5756
5757 // Test 1: Target is a directory, should highlight the directory itself
5758 let dir_entry = worktree.entry_for_path("dir1").unwrap();
5759 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
5760 assert_eq!(
5761 result,
5762 Some(dir_entry.id),
5763 "Should highlight directory itself"
5764 );
5765
5766 // Test 2: Target is nested file, should highlight immediate parent
5767 let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
5768 let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
5769 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
5770 assert_eq!(
5771 result,
5772 Some(nested_parent.id),
5773 "Should highlight immediate parent"
5774 );
5775
5776 // Test 3: Target is root level file, should highlight root
5777 let root_file = worktree.entry_for_path("file3.txt").unwrap();
5778 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
5779 assert_eq!(
5780 result,
5781 Some(worktree.root_entry().unwrap().id),
5782 "Root level file should return None"
5783 );
5784
5785 // Test 4: Target is root itself, should highlight root
5786 let root_entry = worktree.root_entry().unwrap();
5787 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
5788 assert_eq!(
5789 result,
5790 Some(root_entry.id),
5791 "Root level file should return None"
5792 );
5793 });
5794}
5795
5796#[gpui::test]
5797async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
5798 init_test(cx);
5799
5800 let fs = FakeFs::new(cx.executor());
5801 fs.insert_tree(
5802 "/root",
5803 json!({
5804 "parent_dir": {
5805 "child_file.txt": "",
5806 "sibling_file.txt": "",
5807 "child_dir": {
5808 "nested_file.txt": ""
5809 }
5810 },
5811 "other_dir": {
5812 "other_file.txt": ""
5813 }
5814 }),
5815 )
5816 .await;
5817
5818 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5819 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5820 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5821 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5822
5823 panel.update(cx, |panel, cx| {
5824 let project = panel.project.read(cx);
5825 let worktree = project.visible_worktrees(cx).next().unwrap();
5826 let worktree_id = worktree.read(cx).id();
5827 let worktree = worktree.read(cx);
5828
5829 let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
5830 let child_file = worktree
5831 .entry_for_path("parent_dir/child_file.txt")
5832 .unwrap();
5833 let sibling_file = worktree
5834 .entry_for_path("parent_dir/sibling_file.txt")
5835 .unwrap();
5836 let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
5837 let other_dir = worktree.entry_for_path("other_dir").unwrap();
5838 let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
5839
5840 // Test 1: Single item drag, don't highlight parent directory
5841 let dragged_selection = DraggedSelection {
5842 active_selection: SelectedEntry {
5843 worktree_id,
5844 entry_id: child_file.id,
5845 },
5846 marked_selections: Arc::new([SelectedEntry {
5847 worktree_id,
5848 entry_id: child_file.id,
5849 }]),
5850 };
5851 let result =
5852 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5853 assert_eq!(result, None, "Should not highlight parent of dragged item");
5854
5855 // Test 2: Single item drag, don't highlight sibling files
5856 let result = panel.highlight_entry_for_selection_drag(
5857 sibling_file,
5858 worktree,
5859 &dragged_selection,
5860 cx,
5861 );
5862 assert_eq!(result, None, "Should not highlight sibling files");
5863
5864 // Test 3: Single item drag, highlight unrelated directory
5865 let result =
5866 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
5867 assert_eq!(
5868 result,
5869 Some(other_dir.id),
5870 "Should highlight unrelated directory"
5871 );
5872
5873 // Test 4: Single item drag, highlight sibling directory
5874 let result =
5875 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5876 assert_eq!(
5877 result,
5878 Some(child_dir.id),
5879 "Should highlight sibling directory"
5880 );
5881
5882 // Test 5: Multiple items drag, highlight parent directory
5883 let dragged_selection = DraggedSelection {
5884 active_selection: SelectedEntry {
5885 worktree_id,
5886 entry_id: child_file.id,
5887 },
5888 marked_selections: Arc::new([
5889 SelectedEntry {
5890 worktree_id,
5891 entry_id: child_file.id,
5892 },
5893 SelectedEntry {
5894 worktree_id,
5895 entry_id: sibling_file.id,
5896 },
5897 ]),
5898 };
5899 let result =
5900 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5901 assert_eq!(
5902 result,
5903 Some(parent_dir.id),
5904 "Should highlight parent with multiple items"
5905 );
5906
5907 // Test 6: Target is file in different directory, highlight parent
5908 let result =
5909 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
5910 assert_eq!(
5911 result,
5912 Some(other_dir.id),
5913 "Should highlight parent of target file"
5914 );
5915
5916 // Test 7: Target is directory, always highlight
5917 let result =
5918 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5919 assert_eq!(
5920 result,
5921 Some(child_dir.id),
5922 "Should always highlight directories"
5923 );
5924 });
5925}
5926
5927#[gpui::test]
5928async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
5929 init_test(cx);
5930
5931 let fs = FakeFs::new(cx.executor());
5932 fs.insert_tree(
5933 "/root1",
5934 json!({
5935 "src": {
5936 "main.rs": "",
5937 "lib.rs": ""
5938 }
5939 }),
5940 )
5941 .await;
5942 fs.insert_tree(
5943 "/root2",
5944 json!({
5945 "src": {
5946 "main.rs": "",
5947 "test.rs": ""
5948 }
5949 }),
5950 )
5951 .await;
5952
5953 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5954 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5955 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5956 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5957
5958 panel.update(cx, |panel, cx| {
5959 let project = panel.project.read(cx);
5960 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
5961
5962 let worktree_a = &worktrees[0];
5963 let main_rs_from_a = worktree_a.read(cx).entry_for_path("src/main.rs").unwrap();
5964
5965 let worktree_b = &worktrees[1];
5966 let src_dir_from_b = worktree_b.read(cx).entry_for_path("src").unwrap();
5967 let main_rs_from_b = worktree_b.read(cx).entry_for_path("src/main.rs").unwrap();
5968
5969 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
5970 let dragged_selection = DraggedSelection {
5971 active_selection: SelectedEntry {
5972 worktree_id: worktree_a.read(cx).id(),
5973 entry_id: main_rs_from_a.id,
5974 },
5975 marked_selections: Arc::new([SelectedEntry {
5976 worktree_id: worktree_a.read(cx).id(),
5977 entry_id: main_rs_from_a.id,
5978 }]),
5979 };
5980
5981 let result = panel.highlight_entry_for_selection_drag(
5982 src_dir_from_b,
5983 worktree_b.read(cx),
5984 &dragged_selection,
5985 cx,
5986 );
5987 assert_eq!(
5988 result,
5989 Some(src_dir_from_b.id),
5990 "Should highlight target directory from different worktree even with same relative path"
5991 );
5992
5993 // Test dragging file from worktree A onto file with same relative path in worktree B
5994 let result = panel.highlight_entry_for_selection_drag(
5995 main_rs_from_b,
5996 worktree_b.read(cx),
5997 &dragged_selection,
5998 cx,
5999 );
6000 assert_eq!(
6001 result,
6002 Some(src_dir_from_b.id),
6003 "Should highlight parent of target file from different worktree"
6004 );
6005 });
6006}
6007
6008#[gpui::test]
6009async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
6010 init_test(cx);
6011
6012 let fs = FakeFs::new(cx.executor());
6013 fs.insert_tree(
6014 "/root1",
6015 json!({
6016 "parent_dir": {
6017 "child_file.txt": "",
6018 "nested_dir": {
6019 "nested_file.txt": ""
6020 }
6021 },
6022 "root_file.txt": ""
6023 }),
6024 )
6025 .await;
6026
6027 fs.insert_tree(
6028 "/root2",
6029 json!({
6030 "other_dir": {
6031 "other_file.txt": ""
6032 }
6033 }),
6034 )
6035 .await;
6036
6037 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6038 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6039 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6040 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6041
6042 panel.update(cx, |panel, cx| {
6043 let project = panel.project.read(cx);
6044 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
6045 let worktree1 = worktrees[0].read(cx);
6046 let worktree2 = worktrees[1].read(cx);
6047 let worktree1_id = worktree1.id();
6048 let _worktree2_id = worktree2.id();
6049
6050 let root1_entry = worktree1.root_entry().unwrap();
6051 let root2_entry = worktree2.root_entry().unwrap();
6052 let _parent_dir = worktree1.entry_for_path("parent_dir").unwrap();
6053 let child_file = worktree1
6054 .entry_for_path("parent_dir/child_file.txt")
6055 .unwrap();
6056 let nested_file = worktree1
6057 .entry_for_path("parent_dir/nested_dir/nested_file.txt")
6058 .unwrap();
6059 let root_file = worktree1.entry_for_path("root_file.txt").unwrap();
6060
6061 // Test 1: Multiple entries - should always highlight background
6062 let multiple_dragged_selection = DraggedSelection {
6063 active_selection: SelectedEntry {
6064 worktree_id: worktree1_id,
6065 entry_id: child_file.id,
6066 },
6067 marked_selections: Arc::new([
6068 SelectedEntry {
6069 worktree_id: worktree1_id,
6070 entry_id: child_file.id,
6071 },
6072 SelectedEntry {
6073 worktree_id: worktree1_id,
6074 entry_id: nested_file.id,
6075 },
6076 ]),
6077 };
6078
6079 let result = panel.should_highlight_background_for_selection_drag(
6080 &multiple_dragged_selection,
6081 root1_entry.id,
6082 cx,
6083 );
6084 assert!(result, "Should highlight background for multiple entries");
6085
6086 // Test 2: Single entry with non-empty parent path - should highlight background
6087 let nested_dragged_selection = DraggedSelection {
6088 active_selection: SelectedEntry {
6089 worktree_id: worktree1_id,
6090 entry_id: nested_file.id,
6091 },
6092 marked_selections: Arc::new([SelectedEntry {
6093 worktree_id: worktree1_id,
6094 entry_id: nested_file.id,
6095 }]),
6096 };
6097
6098 let result = panel.should_highlight_background_for_selection_drag(
6099 &nested_dragged_selection,
6100 root1_entry.id,
6101 cx,
6102 );
6103 assert!(result, "Should highlight background for nested file");
6104
6105 // Test 3: Single entry at root level, same worktree - should NOT highlight background
6106 let root_file_dragged_selection = DraggedSelection {
6107 active_selection: SelectedEntry {
6108 worktree_id: worktree1_id,
6109 entry_id: root_file.id,
6110 },
6111 marked_selections: Arc::new([SelectedEntry {
6112 worktree_id: worktree1_id,
6113 entry_id: root_file.id,
6114 }]),
6115 };
6116
6117 let result = panel.should_highlight_background_for_selection_drag(
6118 &root_file_dragged_selection,
6119 root1_entry.id,
6120 cx,
6121 );
6122 assert!(
6123 !result,
6124 "Should NOT highlight background for root file in same worktree"
6125 );
6126
6127 // Test 4: Single entry at root level, different worktree - should highlight background
6128 let result = panel.should_highlight_background_for_selection_drag(
6129 &root_file_dragged_selection,
6130 root2_entry.id,
6131 cx,
6132 );
6133 assert!(
6134 result,
6135 "Should highlight background for root file from different worktree"
6136 );
6137
6138 // Test 5: Single entry in subdirectory - should highlight background
6139 let child_file_dragged_selection = DraggedSelection {
6140 active_selection: SelectedEntry {
6141 worktree_id: worktree1_id,
6142 entry_id: child_file.id,
6143 },
6144 marked_selections: Arc::new([SelectedEntry {
6145 worktree_id: worktree1_id,
6146 entry_id: child_file.id,
6147 }]),
6148 };
6149
6150 let result = panel.should_highlight_background_for_selection_drag(
6151 &child_file_dragged_selection,
6152 root1_entry.id,
6153 cx,
6154 );
6155 assert!(
6156 result,
6157 "Should highlight background for file with non-empty parent path"
6158 );
6159 });
6160}
6161
6162#[gpui::test]
6163async fn test_hide_root(cx: &mut gpui::TestAppContext) {
6164 init_test(cx);
6165
6166 let fs = FakeFs::new(cx.executor());
6167 fs.insert_tree(
6168 "/root1",
6169 json!({
6170 "dir1": {
6171 "file1.txt": "content",
6172 "file2.txt": "content",
6173 },
6174 "dir2": {
6175 "file3.txt": "content",
6176 },
6177 "file4.txt": "content",
6178 }),
6179 )
6180 .await;
6181
6182 fs.insert_tree(
6183 "/root2",
6184 json!({
6185 "dir3": {
6186 "file5.txt": "content",
6187 },
6188 "file6.txt": "content",
6189 }),
6190 )
6191 .await;
6192
6193 // Test 1: Single worktree with hide_root = false
6194 {
6195 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6196 let workspace =
6197 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6198 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6199
6200 cx.update(|_, cx| {
6201 let settings = *ProjectPanelSettings::get_global(cx);
6202 ProjectPanelSettings::override_global(
6203 ProjectPanelSettings {
6204 hide_root: false,
6205 ..settings
6206 },
6207 cx,
6208 );
6209 });
6210
6211 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6212
6213 #[rustfmt::skip]
6214 assert_eq!(
6215 visible_entries_as_strings(&panel, 0..10, cx),
6216 &[
6217 "v root1",
6218 " > dir1",
6219 " > dir2",
6220 " file4.txt",
6221 ],
6222 "With hide_root=false and single worktree, root should be visible"
6223 );
6224 }
6225
6226 // Test 2: Single worktree with hide_root = true
6227 {
6228 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6229 let workspace =
6230 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6231 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6232
6233 // Set hide_root to true
6234 cx.update(|_, cx| {
6235 let settings = *ProjectPanelSettings::get_global(cx);
6236 ProjectPanelSettings::override_global(
6237 ProjectPanelSettings {
6238 hide_root: true,
6239 ..settings
6240 },
6241 cx,
6242 );
6243 });
6244
6245 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6246
6247 assert_eq!(
6248 visible_entries_as_strings(&panel, 0..10, cx),
6249 &["> dir1", "> dir2", " file4.txt",],
6250 "With hide_root=true and single worktree, root should be hidden"
6251 );
6252
6253 // Test expanding directories still works without root
6254 toggle_expand_dir(&panel, "root1/dir1", cx);
6255 assert_eq!(
6256 visible_entries_as_strings(&panel, 0..10, cx),
6257 &[
6258 "v dir1 <== selected",
6259 " file1.txt",
6260 " file2.txt",
6261 "> dir2",
6262 " file4.txt",
6263 ],
6264 "Should be able to expand directories even when root is hidden"
6265 );
6266 }
6267
6268 // Test 3: Multiple worktrees with hide_root = true
6269 {
6270 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6271 let workspace =
6272 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6273 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6274
6275 // Set hide_root to true
6276 cx.update(|_, cx| {
6277 let settings = *ProjectPanelSettings::get_global(cx);
6278 ProjectPanelSettings::override_global(
6279 ProjectPanelSettings {
6280 hide_root: true,
6281 ..settings
6282 },
6283 cx,
6284 );
6285 });
6286
6287 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6288
6289 assert_eq!(
6290 visible_entries_as_strings(&panel, 0..10, cx),
6291 &[
6292 "v root1",
6293 " > dir1",
6294 " > dir2",
6295 " file4.txt",
6296 "v root2",
6297 " > dir3",
6298 " file6.txt",
6299 ],
6300 "With hide_root=true and multiple worktrees, roots should still be visible"
6301 );
6302 }
6303
6304 // Test 4: Multiple worktrees with hide_root = false
6305 {
6306 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6307 let workspace =
6308 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6309 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6310
6311 cx.update(|_, cx| {
6312 let settings = *ProjectPanelSettings::get_global(cx);
6313 ProjectPanelSettings::override_global(
6314 ProjectPanelSettings {
6315 hide_root: false,
6316 ..settings
6317 },
6318 cx,
6319 );
6320 });
6321
6322 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6323
6324 assert_eq!(
6325 visible_entries_as_strings(&panel, 0..10, cx),
6326 &[
6327 "v root1",
6328 " > dir1",
6329 " > dir2",
6330 " file4.txt",
6331 "v root2",
6332 " > dir3",
6333 " file6.txt",
6334 ],
6335 "With hide_root=false and multiple worktrees, roots should be visible"
6336 );
6337 }
6338}
6339
6340#[gpui::test]
6341async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
6342 init_test_with_editor(cx);
6343
6344 let fs = FakeFs::new(cx.executor());
6345 fs.insert_tree(
6346 "/root",
6347 json!({
6348 "file1.txt": "content of file1",
6349 "file2.txt": "content of file2",
6350 "dir1": {
6351 "file3.txt": "content of file3"
6352 }
6353 }),
6354 )
6355 .await;
6356
6357 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6358 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6359 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6360 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6361
6362 let file1_path = path!("root/file1.txt");
6363 let file2_path = path!("root/file2.txt");
6364 select_path_with_mark(&panel, file1_path, cx);
6365 select_path_with_mark(&panel, file2_path, cx);
6366
6367 panel.update_in(cx, |panel, window, cx| {
6368 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
6369 });
6370 cx.executor().run_until_parked();
6371
6372 workspace
6373 .update(cx, |workspace, _, cx| {
6374 let active_items = workspace
6375 .panes()
6376 .iter()
6377 .filter_map(|pane| pane.read(cx).active_item())
6378 .collect::<Vec<_>>();
6379 assert_eq!(active_items.len(), 1);
6380 let diff_view = active_items
6381 .into_iter()
6382 .next()
6383 .unwrap()
6384 .downcast::<FileDiffView>()
6385 .expect("Open item should be an FileDiffView");
6386 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
6387 assert_eq!(
6388 diff_view.tab_tooltip_text(cx).unwrap(),
6389 format!("{} ↔ {}", file1_path, file2_path)
6390 );
6391 })
6392 .unwrap();
6393
6394 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
6395 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
6396 let worktree_id = panel.update(cx, |panel, cx| {
6397 panel
6398 .project
6399 .read(cx)
6400 .worktrees(cx)
6401 .next()
6402 .unwrap()
6403 .read(cx)
6404 .id()
6405 });
6406
6407 let expected_entries = [
6408 SelectedEntry {
6409 worktree_id,
6410 entry_id: file1_entry_id,
6411 },
6412 SelectedEntry {
6413 worktree_id,
6414 entry_id: file2_entry_id,
6415 },
6416 ];
6417 panel.update(cx, |panel, _cx| {
6418 assert_eq!(
6419 &panel.marked_entries, &expected_entries,
6420 "Should keep marked entries after comparison"
6421 );
6422 });
6423
6424 panel.update(cx, |panel, cx| {
6425 panel.project.update(cx, |_, cx| {
6426 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
6427 })
6428 });
6429
6430 panel.update(cx, |panel, _cx| {
6431 assert_eq!(
6432 &panel.marked_entries, &expected_entries,
6433 "Marked entries should persist after focusing back on the project panel"
6434 );
6435 });
6436}
6437
6438#[gpui::test]
6439async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
6440 init_test_with_editor(cx);
6441
6442 let fs = FakeFs::new(cx.executor());
6443 fs.insert_tree(
6444 "/root",
6445 json!({
6446 "file1.txt": "content of file1",
6447 "file2.txt": "content of file2",
6448 "dir1": {},
6449 "dir2": {
6450 "file3.txt": "content of file3"
6451 }
6452 }),
6453 )
6454 .await;
6455
6456 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6457 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6458 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6459 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6460
6461 // Test 1: When only one file is selected, there should be no compare option
6462 select_path(&panel, "root/file1.txt", cx);
6463
6464 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6465 assert_eq!(
6466 selected_files, None,
6467 "Should not have compare option when only one file is selected"
6468 );
6469
6470 // Test 2: When multiple files are selected, there should be a compare option
6471 select_path_with_mark(&panel, "root/file1.txt", cx);
6472 select_path_with_mark(&panel, "root/file2.txt", cx);
6473
6474 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6475 assert!(
6476 selected_files.is_some(),
6477 "Should have files selected for comparison"
6478 );
6479 if let Some((file1, file2)) = selected_files {
6480 assert!(
6481 file1.to_string_lossy().ends_with("file1.txt")
6482 && file2.to_string_lossy().ends_with("file2.txt"),
6483 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
6484 );
6485 }
6486
6487 // Test 3: Selecting a directory shouldn't count as a comparable file
6488 select_path_with_mark(&panel, "root/dir1", cx);
6489
6490 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6491 assert!(
6492 selected_files.is_some(),
6493 "Directory selection should not affect comparable files"
6494 );
6495 if let Some((file1, file2)) = selected_files {
6496 assert!(
6497 file1.to_string_lossy().ends_with("file1.txt")
6498 && file2.to_string_lossy().ends_with("file2.txt"),
6499 "Selecting a directory should not affect the number of comparable files"
6500 );
6501 }
6502
6503 // Test 4: Selecting one more file
6504 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
6505
6506 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6507 assert!(
6508 selected_files.is_some(),
6509 "Directory selection should not affect comparable files"
6510 );
6511 if let Some((file1, file2)) = selected_files {
6512 assert!(
6513 file1.to_string_lossy().ends_with("file2.txt")
6514 && file2.to_string_lossy().ends_with("file3.txt"),
6515 "Selecting a directory should not affect the number of comparable files"
6516 );
6517 }
6518}
6519
6520fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
6521 let path = path.as_ref();
6522 panel.update(cx, |panel, cx| {
6523 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6524 let worktree = worktree.read(cx);
6525 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6526 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6527 panel.selection = Some(crate::SelectedEntry {
6528 worktree_id: worktree.id(),
6529 entry_id,
6530 });
6531 return;
6532 }
6533 }
6534 panic!("no worktree for path {:?}", path);
6535 });
6536}
6537
6538fn select_path_with_mark(
6539 panel: &Entity<ProjectPanel>,
6540 path: impl AsRef<Path>,
6541 cx: &mut VisualTestContext,
6542) {
6543 let path = path.as_ref();
6544 panel.update(cx, |panel, cx| {
6545 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6546 let worktree = worktree.read(cx);
6547 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6548 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6549 let entry = crate::SelectedEntry {
6550 worktree_id: worktree.id(),
6551 entry_id,
6552 };
6553 if !panel.marked_entries.contains(&entry) {
6554 panel.marked_entries.push(entry);
6555 }
6556 panel.selection = Some(entry);
6557 return;
6558 }
6559 }
6560 panic!("no worktree for path {:?}", path);
6561 });
6562}
6563
6564fn find_project_entry(
6565 panel: &Entity<ProjectPanel>,
6566 path: impl AsRef<Path>,
6567 cx: &mut VisualTestContext,
6568) -> Option<ProjectEntryId> {
6569 let path = path.as_ref();
6570 panel.update(cx, |panel, cx| {
6571 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6572 let worktree = worktree.read(cx);
6573 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6574 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6575 }
6576 }
6577 panic!("no worktree for path {path:?}");
6578 })
6579}
6580
6581fn visible_entries_as_strings(
6582 panel: &Entity<ProjectPanel>,
6583 range: Range<usize>,
6584 cx: &mut VisualTestContext,
6585) -> Vec<String> {
6586 let mut result = Vec::new();
6587 let mut project_entries = HashSet::default();
6588 let mut has_editor = false;
6589
6590 panel.update_in(cx, |panel, window, cx| {
6591 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
6592 if details.is_editing {
6593 assert!(!has_editor, "duplicate editor entry");
6594 has_editor = true;
6595 } else {
6596 assert!(
6597 project_entries.insert(project_entry),
6598 "duplicate project entry {:?} {:?}",
6599 project_entry,
6600 details
6601 );
6602 }
6603
6604 let indent = " ".repeat(details.depth);
6605 let icon = if details.kind.is_dir() {
6606 if details.is_expanded { "v " } else { "> " }
6607 } else {
6608 " "
6609 };
6610 #[cfg(windows)]
6611 let filename = details.filename.replace("\\", "/");
6612 #[cfg(not(windows))]
6613 let filename = details.filename;
6614 let name = if details.is_editing {
6615 format!("[EDITOR: '{}']", filename)
6616 } else if details.is_processing {
6617 format!("[PROCESSING: '{}']", filename)
6618 } else {
6619 filename
6620 };
6621 let selected = if details.is_selected {
6622 " <== selected"
6623 } else {
6624 ""
6625 };
6626 let marked = if details.is_marked {
6627 " <== marked"
6628 } else {
6629 ""
6630 };
6631
6632 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6633 });
6634 });
6635
6636 result
6637}
6638
6639fn init_test(cx: &mut TestAppContext) {
6640 cx.update(|cx| {
6641 let settings_store = SettingsStore::test(cx);
6642 cx.set_global(settings_store);
6643 init_settings(cx);
6644 theme::init(theme::LoadThemes::JustBase, cx);
6645 language::init(cx);
6646 editor::init_settings(cx);
6647 crate::init(cx);
6648 workspace::init_settings(cx);
6649 client::init_settings(cx);
6650 Project::init_settings(cx);
6651
6652 cx.update_global::<SettingsStore, _>(|store, cx| {
6653 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6654 project_panel_settings.auto_fold_dirs = Some(false);
6655 });
6656 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6657 worktree_settings.file_scan_exclusions = Some(Vec::new());
6658 });
6659 });
6660 });
6661}
6662
6663fn init_test_with_editor(cx: &mut TestAppContext) {
6664 cx.update(|cx| {
6665 let app_state = AppState::test(cx);
6666 theme::init(theme::LoadThemes::JustBase, cx);
6667 init_settings(cx);
6668 language::init(cx);
6669 editor::init(cx);
6670 crate::init(cx);
6671 workspace::init(app_state, cx);
6672 Project::init_settings(cx);
6673
6674 cx.update_global::<SettingsStore, _>(|store, cx| {
6675 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6676 project_panel_settings.auto_fold_dirs = Some(false);
6677 });
6678 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6679 worktree_settings.file_scan_exclusions = Some(Vec::new());
6680 });
6681 });
6682 });
6683}
6684
6685fn ensure_single_file_is_opened(
6686 window: &WindowHandle<Workspace>,
6687 expected_path: &str,
6688 cx: &mut TestAppContext,
6689) {
6690 window
6691 .update(cx, |workspace, _, cx| {
6692 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6693 assert_eq!(worktrees.len(), 1);
6694 let worktree_id = worktrees[0].read(cx).id();
6695
6696 let open_project_paths = workspace
6697 .panes()
6698 .iter()
6699 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6700 .collect::<Vec<_>>();
6701 assert_eq!(
6702 open_project_paths,
6703 vec![ProjectPath {
6704 worktree_id,
6705 path: Arc::from(Path::new(expected_path))
6706 }],
6707 "Should have opened file, selected in project panel"
6708 );
6709 })
6710 .unwrap();
6711}
6712
6713fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6714 assert!(
6715 !cx.has_pending_prompt(),
6716 "Should have no prompts before the deletion"
6717 );
6718 panel.update_in(cx, |panel, window, cx| {
6719 panel.delete(&Delete { skip_prompt: false }, window, cx)
6720 });
6721 assert!(
6722 cx.has_pending_prompt(),
6723 "Should have a prompt after the deletion"
6724 );
6725 cx.simulate_prompt_answer("Delete");
6726 assert!(
6727 !cx.has_pending_prompt(),
6728 "Should have no prompts after prompt was replied to"
6729 );
6730 cx.executor().run_until_parked();
6731}
6732
6733fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6734 assert!(
6735 !cx.has_pending_prompt(),
6736 "Should have no prompts before the deletion"
6737 );
6738 panel.update_in(cx, |panel, window, cx| {
6739 panel.delete(&Delete { skip_prompt: true }, window, cx)
6740 });
6741 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
6742 cx.executor().run_until_parked();
6743}
6744
6745fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
6746 assert!(
6747 !cx.has_pending_prompt(),
6748 "Should have no prompts after deletion operation closes the file"
6749 );
6750 workspace
6751 .read_with(cx, |workspace, cx| {
6752 let open_project_paths = workspace
6753 .panes()
6754 .iter()
6755 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6756 .collect::<Vec<_>>();
6757 assert!(
6758 open_project_paths.is_empty(),
6759 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
6760 );
6761 })
6762 .unwrap();
6763}
6764
6765struct TestProjectItemView {
6766 focus_handle: FocusHandle,
6767 path: ProjectPath,
6768}
6769
6770struct TestProjectItem {
6771 path: ProjectPath,
6772}
6773
6774impl project::ProjectItem for TestProjectItem {
6775 fn try_open(
6776 _project: &Entity<Project>,
6777 path: &ProjectPath,
6778 cx: &mut App,
6779 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
6780 let path = path.clone();
6781 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
6782 }
6783
6784 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
6785 None
6786 }
6787
6788 fn project_path(&self, _: &App) -> Option<ProjectPath> {
6789 Some(self.path.clone())
6790 }
6791
6792 fn is_dirty(&self) -> bool {
6793 false
6794 }
6795}
6796
6797impl ProjectItem for TestProjectItemView {
6798 type Item = TestProjectItem;
6799
6800 fn for_project_item(
6801 _: Entity<Project>,
6802 _: Option<&Pane>,
6803 project_item: Entity<Self::Item>,
6804 _: &mut Window,
6805 cx: &mut Context<Self>,
6806 ) -> Self
6807 where
6808 Self: Sized,
6809 {
6810 Self {
6811 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
6812 focus_handle: cx.focus_handle(),
6813 }
6814 }
6815}
6816
6817impl Item for TestProjectItemView {
6818 type Event = ();
6819
6820 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
6821 "Test".into()
6822 }
6823}
6824
6825impl EventEmitter<()> for TestProjectItemView {}
6826
6827impl Focusable for TestProjectItemView {
6828 fn focus_handle(&self, _: &App) -> FocusHandle {
6829 self.focus_handle.clone()
6830 }
6831}
6832
6833impl Render for TestProjectItemView {
6834 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
6835 Empty
6836 }
6837}