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_new_file_move(cx: &mut gpui::TestAppContext) {
2752 init_test(cx);
2753
2754 let fs = FakeFs::new(cx.executor());
2755 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
2756 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
2757 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2758 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2759 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2760
2761 // Make a new buffer with no backing file
2762 workspace
2763 .update(cx, |workspace, window, cx| {
2764 Editor::new_file(workspace, &Default::default(), window, cx)
2765 })
2766 .unwrap();
2767
2768 cx.executor().run_until_parked();
2769
2770 // "Save as" the buffer, creating a new backing file for it
2771 let save_task = workspace
2772 .update(cx, |workspace, window, cx| {
2773 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2774 })
2775 .unwrap();
2776
2777 cx.executor().run_until_parked();
2778 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
2779 save_task.await.unwrap();
2780
2781 // Rename the file
2782 select_path(&panel, "root/new", cx);
2783 assert_eq!(
2784 visible_entries_as_strings(&panel, 0..10, cx),
2785 &["v root", " new <== selected <== marked"]
2786 );
2787 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2788 panel.update_in(cx, |panel, window, cx| {
2789 panel
2790 .filename_editor
2791 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
2792 });
2793 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2794
2795 cx.executor().run_until_parked();
2796 assert_eq!(
2797 visible_entries_as_strings(&panel, 0..10, cx),
2798 &["v root", " newer <== selected"]
2799 );
2800
2801 workspace
2802 .update(cx, |workspace, window, cx| {
2803 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
2804 })
2805 .unwrap()
2806 .await
2807 .unwrap();
2808
2809 cx.executor().run_until_parked();
2810 // assert that saving the file doesn't restore "new"
2811 assert_eq!(
2812 visible_entries_as_strings(&panel, 0..10, cx),
2813 &["v root", " newer <== selected"]
2814 );
2815}
2816
2817#[gpui::test]
2818#[cfg_attr(target_os = "windows", ignore)]
2819async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
2820 init_test_with_editor(cx);
2821
2822 let fs = FakeFs::new(cx.executor());
2823 fs.insert_tree(
2824 "/root1",
2825 json!({
2826 "dir1": {
2827 "file1.txt": "content 1",
2828 },
2829 }),
2830 )
2831 .await;
2832
2833 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2834 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2835 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2836 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2837
2838 toggle_expand_dir(&panel, "root1/dir1", cx);
2839
2840 assert_eq!(
2841 visible_entries_as_strings(&panel, 0..20, cx),
2842 &["v root1", " v dir1 <== selected", " file1.txt",],
2843 "Initial state with worktrees"
2844 );
2845
2846 select_path(&panel, "root1", cx);
2847 assert_eq!(
2848 visible_entries_as_strings(&panel, 0..20, cx),
2849 &["v root1 <== selected", " v dir1", " file1.txt",],
2850 );
2851
2852 // Rename root1 to new_root1
2853 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2854
2855 assert_eq!(
2856 visible_entries_as_strings(&panel, 0..20, cx),
2857 &[
2858 "v [EDITOR: 'root1'] <== selected",
2859 " v dir1",
2860 " file1.txt",
2861 ],
2862 );
2863
2864 let confirm = panel.update_in(cx, |panel, window, cx| {
2865 panel
2866 .filename_editor
2867 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
2868 panel.confirm_edit(window, cx).unwrap()
2869 });
2870 confirm.await.unwrap();
2871 assert_eq!(
2872 visible_entries_as_strings(&panel, 0..20, cx),
2873 &[
2874 "v new_root1 <== selected",
2875 " v dir1",
2876 " file1.txt",
2877 ],
2878 "Should update worktree name"
2879 );
2880
2881 // Ensure internal paths have been updated
2882 select_path(&panel, "new_root1/dir1/file1.txt", cx);
2883 assert_eq!(
2884 visible_entries_as_strings(&panel, 0..20, cx),
2885 &[
2886 "v new_root1",
2887 " v dir1",
2888 " file1.txt <== selected",
2889 ],
2890 "Files in renamed worktree are selectable"
2891 );
2892}
2893
2894#[gpui::test]
2895async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
2896 init_test_with_editor(cx);
2897
2898 let fs = FakeFs::new(cx.executor());
2899 fs.insert_tree(
2900 "/root1",
2901 json!({
2902 "dir1": { "file1.txt": "content" },
2903 "file2.txt": "content",
2904 }),
2905 )
2906 .await;
2907 fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
2908 .await;
2909
2910 // Test 1: Single worktree, hide_root=true - rename should be blocked
2911 {
2912 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2913 let workspace =
2914 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2915 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2916
2917 cx.update(|_, cx| {
2918 let settings = *ProjectPanelSettings::get_global(cx);
2919 ProjectPanelSettings::override_global(
2920 ProjectPanelSettings {
2921 hide_root: true,
2922 ..settings
2923 },
2924 cx,
2925 );
2926 });
2927
2928 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2929
2930 panel.update(cx, |panel, cx| {
2931 let project = panel.project.read(cx);
2932 let worktree = project.visible_worktrees(cx).next().unwrap();
2933 let root_entry = worktree.read(cx).root_entry().unwrap();
2934 panel.selection = Some(SelectedEntry {
2935 worktree_id: worktree.read(cx).id(),
2936 entry_id: root_entry.id,
2937 });
2938 });
2939
2940 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2941
2942 assert!(
2943 panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
2944 "Rename should be blocked when hide_root=true with single worktree"
2945 );
2946 }
2947
2948 // Test 2: Multiple worktrees, hide_root=true - rename should work
2949 {
2950 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2951 let workspace =
2952 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2953 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2954
2955 cx.update(|_, cx| {
2956 let settings = *ProjectPanelSettings::get_global(cx);
2957 ProjectPanelSettings::override_global(
2958 ProjectPanelSettings {
2959 hide_root: true,
2960 ..settings
2961 },
2962 cx,
2963 );
2964 });
2965
2966 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2967 select_path(&panel, "root1", cx);
2968 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2969
2970 #[cfg(target_os = "windows")]
2971 assert!(
2972 panel.read_with(cx, |panel, _| panel.edit_state.is_none()),
2973 "Rename should be blocked on Windows even with multiple worktrees"
2974 );
2975
2976 #[cfg(not(target_os = "windows"))]
2977 {
2978 assert!(
2979 panel.read_with(cx, |panel, _| panel.edit_state.is_some()),
2980 "Rename should work with multiple worktrees on non-Windows when hide_root=true"
2981 );
2982 panel.update_in(cx, |panel, window, cx| {
2983 panel.cancel(&menu::Cancel, window, cx)
2984 });
2985 }
2986 }
2987}
2988
2989#[gpui::test]
2990async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
2991 init_test_with_editor(cx);
2992 let fs = FakeFs::new(cx.executor());
2993 fs.insert_tree(
2994 "/project_root",
2995 json!({
2996 "dir_1": {
2997 "nested_dir": {
2998 "file_a.py": "# File contents",
2999 }
3000 },
3001 "file_1.py": "# File contents",
3002 }),
3003 )
3004 .await;
3005
3006 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3007 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3008 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3009 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3010 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3011 cx.update(|window, cx| {
3012 panel.update(cx, |this, cx| {
3013 this.select_next(&Default::default(), window, cx);
3014 this.expand_selected_entry(&Default::default(), window, cx);
3015 this.expand_selected_entry(&Default::default(), window, cx);
3016 this.select_next(&Default::default(), window, cx);
3017 this.expand_selected_entry(&Default::default(), window, cx);
3018 this.select_next(&Default::default(), window, cx);
3019 })
3020 });
3021 assert_eq!(
3022 visible_entries_as_strings(&panel, 0..10, cx),
3023 &[
3024 "v project_root",
3025 " v dir_1",
3026 " v nested_dir",
3027 " file_a.py <== selected",
3028 " file_1.py",
3029 ]
3030 );
3031 let modifiers_with_shift = gpui::Modifiers {
3032 shift: true,
3033 ..Default::default()
3034 };
3035 cx.run_until_parked();
3036 cx.simulate_modifiers_change(modifiers_with_shift);
3037 cx.update(|window, cx| {
3038 panel.update(cx, |this, cx| {
3039 this.select_next(&Default::default(), window, cx);
3040 })
3041 });
3042 assert_eq!(
3043 visible_entries_as_strings(&panel, 0..10, cx),
3044 &[
3045 "v project_root",
3046 " v dir_1",
3047 " v nested_dir",
3048 " file_a.py",
3049 " file_1.py <== selected <== marked",
3050 ]
3051 );
3052 cx.update(|window, cx| {
3053 panel.update(cx, |this, cx| {
3054 this.select_previous(&Default::default(), window, cx);
3055 })
3056 });
3057 assert_eq!(
3058 visible_entries_as_strings(&panel, 0..10, cx),
3059 &[
3060 "v project_root",
3061 " v dir_1",
3062 " v nested_dir",
3063 " file_a.py <== selected <== marked",
3064 " file_1.py <== marked",
3065 ]
3066 );
3067 cx.update(|window, cx| {
3068 panel.update(cx, |this, cx| {
3069 let drag = DraggedSelection {
3070 active_selection: this.selection.unwrap(),
3071 marked_selections: this.marked_entries.clone().into(),
3072 };
3073 let target_entry = this
3074 .project
3075 .read(cx)
3076 .entry_for_path(&(worktree_id, "").into(), cx)
3077 .unwrap();
3078 this.drag_onto(&drag, target_entry.id, false, window, cx);
3079 });
3080 });
3081 cx.run_until_parked();
3082 assert_eq!(
3083 visible_entries_as_strings(&panel, 0..10, cx),
3084 &[
3085 "v project_root",
3086 " v dir_1",
3087 " v nested_dir",
3088 " file_1.py <== marked",
3089 " file_a.py <== selected <== marked",
3090 ]
3091 );
3092 // ESC clears out all marks
3093 cx.update(|window, cx| {
3094 panel.update(cx, |this, cx| {
3095 this.cancel(&menu::Cancel, window, cx);
3096 })
3097 });
3098 assert_eq!(
3099 visible_entries_as_strings(&panel, 0..10, cx),
3100 &[
3101 "v project_root",
3102 " v dir_1",
3103 " v nested_dir",
3104 " file_1.py",
3105 " file_a.py <== selected",
3106 ]
3107 );
3108 // ESC clears out all marks
3109 cx.update(|window, cx| {
3110 panel.update(cx, |this, cx| {
3111 this.select_previous(&SelectPrevious, window, cx);
3112 this.select_next(&SelectNext, window, cx);
3113 })
3114 });
3115 assert_eq!(
3116 visible_entries_as_strings(&panel, 0..10, cx),
3117 &[
3118 "v project_root",
3119 " v dir_1",
3120 " v nested_dir",
3121 " file_1.py <== marked",
3122 " file_a.py <== selected <== marked",
3123 ]
3124 );
3125 cx.simulate_modifiers_change(Default::default());
3126 cx.update(|window, cx| {
3127 panel.update(cx, |this, cx| {
3128 this.cut(&Cut, window, cx);
3129 this.select_previous(&SelectPrevious, window, cx);
3130 this.select_previous(&SelectPrevious, window, cx);
3131
3132 this.paste(&Paste, window, cx);
3133 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
3134 })
3135 });
3136 cx.run_until_parked();
3137 assert_eq!(
3138 visible_entries_as_strings(&panel, 0..10, cx),
3139 &[
3140 "v project_root",
3141 " v dir_1",
3142 " v nested_dir",
3143 " file_1.py <== marked",
3144 " file_a.py <== selected <== marked",
3145 ]
3146 );
3147 cx.simulate_modifiers_change(modifiers_with_shift);
3148 cx.update(|window, cx| {
3149 panel.update(cx, |this, cx| {
3150 this.expand_selected_entry(&Default::default(), window, cx);
3151 this.select_next(&SelectNext, window, cx);
3152 this.select_next(&SelectNext, window, cx);
3153 })
3154 });
3155 submit_deletion(&panel, cx);
3156 assert_eq!(
3157 visible_entries_as_strings(&panel, 0..10, cx),
3158 &[
3159 "v project_root",
3160 " v dir_1",
3161 " v nested_dir <== selected",
3162 ]
3163 );
3164}
3165#[gpui::test]
3166async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3167 init_test_with_editor(cx);
3168 cx.update(|cx| {
3169 cx.update_global::<SettingsStore, _>(|store, cx| {
3170 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3171 worktree_settings.file_scan_exclusions = Some(Vec::new());
3172 });
3173 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3174 project_panel_settings.auto_reveal_entries = Some(false)
3175 });
3176 })
3177 });
3178
3179 let fs = FakeFs::new(cx.background_executor.clone());
3180 fs.insert_tree(
3181 "/project_root",
3182 json!({
3183 ".git": {},
3184 ".gitignore": "**/gitignored_dir",
3185 "dir_1": {
3186 "file_1.py": "# File 1_1 contents",
3187 "file_2.py": "# File 1_2 contents",
3188 "file_3.py": "# File 1_3 contents",
3189 "gitignored_dir": {
3190 "file_a.py": "# File contents",
3191 "file_b.py": "# File contents",
3192 "file_c.py": "# File contents",
3193 },
3194 },
3195 "dir_2": {
3196 "file_1.py": "# File 2_1 contents",
3197 "file_2.py": "# File 2_2 contents",
3198 "file_3.py": "# File 2_3 contents",
3199 }
3200 }),
3201 )
3202 .await;
3203
3204 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3205 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3206 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3207 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3208
3209 assert_eq!(
3210 visible_entries_as_strings(&panel, 0..20, cx),
3211 &[
3212 "v project_root",
3213 " > .git",
3214 " > dir_1",
3215 " > dir_2",
3216 " .gitignore",
3217 ]
3218 );
3219
3220 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3221 .expect("dir 1 file is not ignored and should have an entry");
3222 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3223 .expect("dir 2 file is not ignored and should have an entry");
3224 let gitignored_dir_file =
3225 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3226 assert_eq!(
3227 gitignored_dir_file, None,
3228 "File in the gitignored dir should not have an entry before its dir is toggled"
3229 );
3230
3231 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3232 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3233 cx.executor().run_until_parked();
3234 assert_eq!(
3235 visible_entries_as_strings(&panel, 0..20, cx),
3236 &[
3237 "v project_root",
3238 " > .git",
3239 " v dir_1",
3240 " v gitignored_dir <== selected",
3241 " file_a.py",
3242 " file_b.py",
3243 " file_c.py",
3244 " file_1.py",
3245 " file_2.py",
3246 " file_3.py",
3247 " > dir_2",
3248 " .gitignore",
3249 ],
3250 "Should show gitignored dir file list in the project panel"
3251 );
3252 let gitignored_dir_file =
3253 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3254 .expect("after gitignored dir got opened, a file entry should be present");
3255
3256 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3257 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3258 assert_eq!(
3259 visible_entries_as_strings(&panel, 0..20, cx),
3260 &[
3261 "v project_root",
3262 " > .git",
3263 " > dir_1 <== selected",
3264 " > dir_2",
3265 " .gitignore",
3266 ],
3267 "Should hide all dir contents again and prepare for the auto reveal test"
3268 );
3269
3270 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3271 panel.update(cx, |panel, cx| {
3272 panel.project.update(cx, |_, cx| {
3273 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3274 })
3275 });
3276 cx.run_until_parked();
3277 assert_eq!(
3278 visible_entries_as_strings(&panel, 0..20, cx),
3279 &[
3280 "v project_root",
3281 " > .git",
3282 " > dir_1 <== selected",
3283 " > dir_2",
3284 " .gitignore",
3285 ],
3286 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3287 );
3288 }
3289
3290 cx.update(|_, cx| {
3291 cx.update_global::<SettingsStore, _>(|store, cx| {
3292 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3293 project_panel_settings.auto_reveal_entries = Some(true)
3294 });
3295 })
3296 });
3297
3298 panel.update(cx, |panel, cx| {
3299 panel.project.update(cx, |_, cx| {
3300 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3301 })
3302 });
3303 cx.run_until_parked();
3304 assert_eq!(
3305 visible_entries_as_strings(&panel, 0..20, cx),
3306 &[
3307 "v project_root",
3308 " > .git",
3309 " v dir_1",
3310 " > gitignored_dir",
3311 " file_1.py <== selected <== marked",
3312 " file_2.py",
3313 " file_3.py",
3314 " > dir_2",
3315 " .gitignore",
3316 ],
3317 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3318 );
3319
3320 panel.update(cx, |panel, cx| {
3321 panel.project.update(cx, |_, cx| {
3322 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3323 })
3324 });
3325 cx.run_until_parked();
3326 assert_eq!(
3327 visible_entries_as_strings(&panel, 0..20, cx),
3328 &[
3329 "v project_root",
3330 " > .git",
3331 " v dir_1",
3332 " > gitignored_dir",
3333 " file_1.py",
3334 " file_2.py",
3335 " file_3.py",
3336 " v dir_2",
3337 " file_1.py <== selected <== marked",
3338 " file_2.py",
3339 " file_3.py",
3340 " .gitignore",
3341 ],
3342 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3343 );
3344
3345 panel.update(cx, |panel, cx| {
3346 panel.project.update(cx, |_, cx| {
3347 cx.emit(project::Event::ActiveEntryChanged(Some(
3348 gitignored_dir_file,
3349 )))
3350 })
3351 });
3352 cx.run_until_parked();
3353 assert_eq!(
3354 visible_entries_as_strings(&panel, 0..20, cx),
3355 &[
3356 "v project_root",
3357 " > .git",
3358 " v dir_1",
3359 " > gitignored_dir",
3360 " file_1.py",
3361 " file_2.py",
3362 " file_3.py",
3363 " v dir_2",
3364 " file_1.py <== selected <== marked",
3365 " file_2.py",
3366 " file_3.py",
3367 " .gitignore",
3368 ],
3369 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3370 );
3371
3372 panel.update(cx, |panel, cx| {
3373 panel.project.update(cx, |_, cx| {
3374 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3375 })
3376 });
3377 cx.run_until_parked();
3378 assert_eq!(
3379 visible_entries_as_strings(&panel, 0..20, cx),
3380 &[
3381 "v project_root",
3382 " > .git",
3383 " v dir_1",
3384 " v gitignored_dir",
3385 " file_a.py <== selected <== marked",
3386 " file_b.py",
3387 " file_c.py",
3388 " file_1.py",
3389 " file_2.py",
3390 " file_3.py",
3391 " v dir_2",
3392 " file_1.py",
3393 " file_2.py",
3394 " file_3.py",
3395 " .gitignore",
3396 ],
3397 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3398 );
3399}
3400
3401#[gpui::test]
3402async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3403 init_test_with_editor(cx);
3404 cx.update(|cx| {
3405 cx.update_global::<SettingsStore, _>(|store, cx| {
3406 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3407 worktree_settings.file_scan_exclusions = Some(Vec::new());
3408 worktree_settings.file_scan_inclusions =
3409 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3410 });
3411 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3412 project_panel_settings.auto_reveal_entries = Some(false)
3413 });
3414 })
3415 });
3416
3417 let fs = FakeFs::new(cx.background_executor.clone());
3418 fs.insert_tree(
3419 "/project_root",
3420 json!({
3421 ".git": {},
3422 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3423 "dir_1": {
3424 "file_1.py": "# File 1_1 contents",
3425 "file_2.py": "# File 1_2 contents",
3426 "file_3.py": "# File 1_3 contents",
3427 "gitignored_dir": {
3428 "file_a.py": "# File contents",
3429 "file_b.py": "# File contents",
3430 "file_c.py": "# File contents",
3431 },
3432 },
3433 "dir_2": {
3434 "file_1.py": "# File 2_1 contents",
3435 "file_2.py": "# File 2_2 contents",
3436 "file_3.py": "# File 2_3 contents",
3437 },
3438 "always_included_but_ignored_dir": {
3439 "file_a.py": "# File contents",
3440 "file_b.py": "# File contents",
3441 "file_c.py": "# File contents",
3442 },
3443 }),
3444 )
3445 .await;
3446
3447 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3448 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3449 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3450 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3451
3452 assert_eq!(
3453 visible_entries_as_strings(&panel, 0..20, cx),
3454 &[
3455 "v project_root",
3456 " > .git",
3457 " > always_included_but_ignored_dir",
3458 " > dir_1",
3459 " > dir_2",
3460 " .gitignore",
3461 ]
3462 );
3463
3464 let gitignored_dir_file =
3465 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3466 let always_included_but_ignored_dir_file = find_project_entry(
3467 &panel,
3468 "project_root/always_included_but_ignored_dir/file_a.py",
3469 cx,
3470 )
3471 .expect("file that is .gitignored but set to always be included should have an entry");
3472 assert_eq!(
3473 gitignored_dir_file, None,
3474 "File in the gitignored dir should not have an entry unless its directory is toggled"
3475 );
3476
3477 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3478 cx.run_until_parked();
3479 cx.update(|_, cx| {
3480 cx.update_global::<SettingsStore, _>(|store, cx| {
3481 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3482 project_panel_settings.auto_reveal_entries = Some(true)
3483 });
3484 })
3485 });
3486
3487 panel.update(cx, |panel, cx| {
3488 panel.project.update(cx, |_, cx| {
3489 cx.emit(project::Event::ActiveEntryChanged(Some(
3490 always_included_but_ignored_dir_file,
3491 )))
3492 })
3493 });
3494 cx.run_until_parked();
3495
3496 assert_eq!(
3497 visible_entries_as_strings(&panel, 0..20, cx),
3498 &[
3499 "v project_root",
3500 " > .git",
3501 " v always_included_but_ignored_dir",
3502 " file_a.py <== selected <== marked",
3503 " file_b.py",
3504 " file_c.py",
3505 " v dir_1",
3506 " > gitignored_dir",
3507 " file_1.py",
3508 " file_2.py",
3509 " file_3.py",
3510 " > dir_2",
3511 " .gitignore",
3512 ],
3513 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3514 );
3515}
3516
3517#[gpui::test]
3518async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3519 init_test_with_editor(cx);
3520 cx.update(|cx| {
3521 cx.update_global::<SettingsStore, _>(|store, cx| {
3522 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3523 worktree_settings.file_scan_exclusions = Some(Vec::new());
3524 });
3525 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3526 project_panel_settings.auto_reveal_entries = Some(false)
3527 });
3528 })
3529 });
3530
3531 let fs = FakeFs::new(cx.background_executor.clone());
3532 fs.insert_tree(
3533 "/project_root",
3534 json!({
3535 ".git": {},
3536 ".gitignore": "**/gitignored_dir",
3537 "dir_1": {
3538 "file_1.py": "# File 1_1 contents",
3539 "file_2.py": "# File 1_2 contents",
3540 "file_3.py": "# File 1_3 contents",
3541 "gitignored_dir": {
3542 "file_a.py": "# File contents",
3543 "file_b.py": "# File contents",
3544 "file_c.py": "# File contents",
3545 },
3546 },
3547 "dir_2": {
3548 "file_1.py": "# File 2_1 contents",
3549 "file_2.py": "# File 2_2 contents",
3550 "file_3.py": "# File 2_3 contents",
3551 }
3552 }),
3553 )
3554 .await;
3555
3556 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3557 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3558 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3559 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3560
3561 assert_eq!(
3562 visible_entries_as_strings(&panel, 0..20, cx),
3563 &[
3564 "v project_root",
3565 " > .git",
3566 " > dir_1",
3567 " > dir_2",
3568 " .gitignore",
3569 ]
3570 );
3571
3572 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3573 .expect("dir 1 file is not ignored and should have an entry");
3574 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3575 .expect("dir 2 file is not ignored and should have an entry");
3576 let gitignored_dir_file =
3577 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3578 assert_eq!(
3579 gitignored_dir_file, None,
3580 "File in the gitignored dir should not have an entry before its dir is toggled"
3581 );
3582
3583 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3584 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3585 cx.run_until_parked();
3586 assert_eq!(
3587 visible_entries_as_strings(&panel, 0..20, cx),
3588 &[
3589 "v project_root",
3590 " > .git",
3591 " v dir_1",
3592 " v gitignored_dir <== selected",
3593 " file_a.py",
3594 " file_b.py",
3595 " file_c.py",
3596 " file_1.py",
3597 " file_2.py",
3598 " file_3.py",
3599 " > dir_2",
3600 " .gitignore",
3601 ],
3602 "Should show gitignored dir file list in the project panel"
3603 );
3604 let gitignored_dir_file =
3605 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3606 .expect("after gitignored dir got opened, a file entry should be present");
3607
3608 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3609 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3610 assert_eq!(
3611 visible_entries_as_strings(&panel, 0..20, cx),
3612 &[
3613 "v project_root",
3614 " > .git",
3615 " > dir_1 <== selected",
3616 " > dir_2",
3617 " .gitignore",
3618 ],
3619 "Should hide all dir contents again and prepare for the explicit reveal test"
3620 );
3621
3622 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3623 panel.update(cx, |panel, cx| {
3624 panel.project.update(cx, |_, cx| {
3625 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3626 })
3627 });
3628 cx.run_until_parked();
3629 assert_eq!(
3630 visible_entries_as_strings(&panel, 0..20, cx),
3631 &[
3632 "v project_root",
3633 " > .git",
3634 " > dir_1 <== selected",
3635 " > dir_2",
3636 " .gitignore",
3637 ],
3638 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3639 );
3640 }
3641
3642 panel.update(cx, |panel, cx| {
3643 panel.project.update(cx, |_, cx| {
3644 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3645 })
3646 });
3647 cx.run_until_parked();
3648 assert_eq!(
3649 visible_entries_as_strings(&panel, 0..20, cx),
3650 &[
3651 "v project_root",
3652 " > .git",
3653 " v dir_1",
3654 " > gitignored_dir",
3655 " file_1.py <== selected <== marked",
3656 " file_2.py",
3657 " file_3.py",
3658 " > dir_2",
3659 " .gitignore",
3660 ],
3661 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3662 );
3663
3664 panel.update(cx, |panel, cx| {
3665 panel.project.update(cx, |_, cx| {
3666 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3667 })
3668 });
3669 cx.run_until_parked();
3670 assert_eq!(
3671 visible_entries_as_strings(&panel, 0..20, cx),
3672 &[
3673 "v project_root",
3674 " > .git",
3675 " v dir_1",
3676 " > gitignored_dir",
3677 " file_1.py",
3678 " file_2.py",
3679 " file_3.py",
3680 " v dir_2",
3681 " file_1.py <== selected <== marked",
3682 " file_2.py",
3683 " file_3.py",
3684 " .gitignore",
3685 ],
3686 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3687 );
3688
3689 panel.update(cx, |panel, cx| {
3690 panel.project.update(cx, |_, cx| {
3691 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3692 })
3693 });
3694 cx.run_until_parked();
3695 assert_eq!(
3696 visible_entries_as_strings(&panel, 0..20, cx),
3697 &[
3698 "v project_root",
3699 " > .git",
3700 " v dir_1",
3701 " v gitignored_dir",
3702 " file_a.py <== selected <== marked",
3703 " file_b.py",
3704 " file_c.py",
3705 " file_1.py",
3706 " file_2.py",
3707 " file_3.py",
3708 " v dir_2",
3709 " file_1.py",
3710 " file_2.py",
3711 " file_3.py",
3712 " .gitignore",
3713 ],
3714 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3715 );
3716}
3717
3718#[gpui::test]
3719async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
3720 init_test(cx);
3721 cx.update(|cx| {
3722 cx.update_global::<SettingsStore, _>(|store, cx| {
3723 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
3724 project_settings.file_scan_exclusions =
3725 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
3726 });
3727 });
3728 });
3729
3730 cx.update(|cx| {
3731 register_project_item::<TestProjectItemView>(cx);
3732 });
3733
3734 let fs = FakeFs::new(cx.executor());
3735 fs.insert_tree(
3736 "/root1",
3737 json!({
3738 ".dockerignore": "",
3739 ".git": {
3740 "HEAD": "",
3741 },
3742 }),
3743 )
3744 .await;
3745
3746 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3747 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3748 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3749 let panel = workspace
3750 .update(cx, |workspace, window, cx| {
3751 let panel = ProjectPanel::new(workspace, window, cx);
3752 workspace.add_panel(panel.clone(), window, cx);
3753 panel
3754 })
3755 .unwrap();
3756
3757 select_path(&panel, "root1", cx);
3758 assert_eq!(
3759 visible_entries_as_strings(&panel, 0..10, cx),
3760 &["v root1 <== selected", " .dockerignore",]
3761 );
3762 workspace
3763 .update(cx, |workspace, _, cx| {
3764 assert!(
3765 workspace.active_item(cx).is_none(),
3766 "Should have no active items in the beginning"
3767 );
3768 })
3769 .unwrap();
3770
3771 let excluded_file_path = ".git/COMMIT_EDITMSG";
3772 let excluded_dir_path = "excluded_dir";
3773
3774 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3775 panel.update_in(cx, |panel, window, cx| {
3776 assert!(panel.filename_editor.read(cx).is_focused(window));
3777 });
3778 panel
3779 .update_in(cx, |panel, window, cx| {
3780 panel.filename_editor.update(cx, |editor, cx| {
3781 editor.set_text(excluded_file_path, window, cx)
3782 });
3783 panel.confirm_edit(window, cx).unwrap()
3784 })
3785 .await
3786 .unwrap();
3787
3788 assert_eq!(
3789 visible_entries_as_strings(&panel, 0..13, cx),
3790 &["v root1", " .dockerignore"],
3791 "Excluded dir should not be shown after opening a file in it"
3792 );
3793 panel.update_in(cx, |panel, window, cx| {
3794 assert!(
3795 !panel.filename_editor.read(cx).is_focused(window),
3796 "Should have closed the file name editor"
3797 );
3798 });
3799 workspace
3800 .update(cx, |workspace, _, cx| {
3801 let active_entry_path = workspace
3802 .active_item(cx)
3803 .expect("should have opened and activated the excluded item")
3804 .act_as::<TestProjectItemView>(cx)
3805 .expect("should have opened the corresponding project item for the excluded item")
3806 .read(cx)
3807 .path
3808 .clone();
3809 assert_eq!(
3810 active_entry_path.path.as_ref(),
3811 Path::new(excluded_file_path),
3812 "Should open the excluded file"
3813 );
3814
3815 assert!(
3816 workspace.notification_ids().is_empty(),
3817 "Should have no notifications after opening an excluded file"
3818 );
3819 })
3820 .unwrap();
3821 assert!(
3822 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
3823 "Should have created the excluded file"
3824 );
3825
3826 select_path(&panel, "root1", cx);
3827 panel.update_in(cx, |panel, window, cx| {
3828 panel.new_directory(&NewDirectory, window, cx)
3829 });
3830 panel.update_in(cx, |panel, window, cx| {
3831 assert!(panel.filename_editor.read(cx).is_focused(window));
3832 });
3833 panel
3834 .update_in(cx, |panel, window, cx| {
3835 panel.filename_editor.update(cx, |editor, cx| {
3836 editor.set_text(excluded_file_path, window, cx)
3837 });
3838 panel.confirm_edit(window, cx).unwrap()
3839 })
3840 .await
3841 .unwrap();
3842
3843 assert_eq!(
3844 visible_entries_as_strings(&panel, 0..13, cx),
3845 &["v root1", " .dockerignore"],
3846 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
3847 );
3848 panel.update_in(cx, |panel, window, cx| {
3849 assert!(
3850 !panel.filename_editor.read(cx).is_focused(window),
3851 "Should have closed the file name editor"
3852 );
3853 });
3854 workspace
3855 .update(cx, |workspace, _, cx| {
3856 let notifications = workspace.notification_ids();
3857 assert_eq!(
3858 notifications.len(),
3859 1,
3860 "Should receive one notification with the error message"
3861 );
3862 workspace.dismiss_notification(notifications.first().unwrap(), cx);
3863 assert!(workspace.notification_ids().is_empty());
3864 })
3865 .unwrap();
3866
3867 select_path(&panel, "root1", cx);
3868 panel.update_in(cx, |panel, window, cx| {
3869 panel.new_directory(&NewDirectory, window, cx)
3870 });
3871 panel.update_in(cx, |panel, window, cx| {
3872 assert!(panel.filename_editor.read(cx).is_focused(window));
3873 });
3874 panel
3875 .update_in(cx, |panel, window, cx| {
3876 panel.filename_editor.update(cx, |editor, cx| {
3877 editor.set_text(excluded_dir_path, window, cx)
3878 });
3879 panel.confirm_edit(window, cx).unwrap()
3880 })
3881 .await
3882 .unwrap();
3883
3884 assert_eq!(
3885 visible_entries_as_strings(&panel, 0..13, cx),
3886 &["v root1", " .dockerignore"],
3887 "Should not change the project panel after trying to create an excluded directory"
3888 );
3889 panel.update_in(cx, |panel, window, cx| {
3890 assert!(
3891 !panel.filename_editor.read(cx).is_focused(window),
3892 "Should have closed the file name editor"
3893 );
3894 });
3895 workspace
3896 .update(cx, |workspace, _, cx| {
3897 let notifications = workspace.notification_ids();
3898 assert_eq!(
3899 notifications.len(),
3900 1,
3901 "Should receive one notification explaining that no directory is actually shown"
3902 );
3903 workspace.dismiss_notification(notifications.first().unwrap(), cx);
3904 assert!(workspace.notification_ids().is_empty());
3905 })
3906 .unwrap();
3907 assert!(
3908 fs.is_dir(Path::new("/root1/excluded_dir")).await,
3909 "Should have created the excluded directory"
3910 );
3911}
3912
3913#[gpui::test]
3914async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
3915 init_test_with_editor(cx);
3916
3917 let fs = FakeFs::new(cx.executor());
3918 fs.insert_tree(
3919 "/src",
3920 json!({
3921 "test": {
3922 "first.rs": "// First Rust file",
3923 "second.rs": "// Second Rust file",
3924 "third.rs": "// Third Rust file",
3925 }
3926 }),
3927 )
3928 .await;
3929
3930 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3931 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3932 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3933 let panel = workspace
3934 .update(cx, |workspace, window, cx| {
3935 let panel = ProjectPanel::new(workspace, window, cx);
3936 workspace.add_panel(panel.clone(), window, cx);
3937 panel
3938 })
3939 .unwrap();
3940
3941 select_path(&panel, "src/", cx);
3942 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3943 cx.executor().run_until_parked();
3944 assert_eq!(
3945 visible_entries_as_strings(&panel, 0..10, cx),
3946 &[
3947 //
3948 "v src <== selected",
3949 " > test"
3950 ]
3951 );
3952 panel.update_in(cx, |panel, window, cx| {
3953 panel.new_directory(&NewDirectory, window, cx)
3954 });
3955 panel.update_in(cx, |panel, window, cx| {
3956 assert!(panel.filename_editor.read(cx).is_focused(window));
3957 });
3958 assert_eq!(
3959 visible_entries_as_strings(&panel, 0..10, cx),
3960 &[
3961 //
3962 "v src",
3963 " > [EDITOR: ''] <== selected",
3964 " > test"
3965 ]
3966 );
3967
3968 panel.update_in(cx, |panel, window, cx| {
3969 panel.cancel(&menu::Cancel, window, cx)
3970 });
3971 assert_eq!(
3972 visible_entries_as_strings(&panel, 0..10, cx),
3973 &[
3974 //
3975 "v src <== selected",
3976 " > test"
3977 ]
3978 );
3979}
3980
3981#[gpui::test]
3982async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
3983 init_test_with_editor(cx);
3984
3985 let fs = FakeFs::new(cx.executor());
3986 fs.insert_tree(
3987 "/root",
3988 json!({
3989 "dir1": {
3990 "subdir1": {},
3991 "file1.txt": "",
3992 "file2.txt": "",
3993 },
3994 "dir2": {
3995 "subdir2": {},
3996 "file3.txt": "",
3997 "file4.txt": "",
3998 },
3999 "file5.txt": "",
4000 "file6.txt": "",
4001 }),
4002 )
4003 .await;
4004
4005 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4006 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4007 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4008 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4009
4010 toggle_expand_dir(&panel, "root/dir1", cx);
4011 toggle_expand_dir(&panel, "root/dir2", cx);
4012
4013 // Test Case 1: Delete middle file in directory
4014 select_path(&panel, "root/dir1/file1.txt", cx);
4015 assert_eq!(
4016 visible_entries_as_strings(&panel, 0..15, cx),
4017 &[
4018 "v root",
4019 " v dir1",
4020 " > subdir1",
4021 " file1.txt <== selected",
4022 " file2.txt",
4023 " v dir2",
4024 " > subdir2",
4025 " file3.txt",
4026 " file4.txt",
4027 " file5.txt",
4028 " file6.txt",
4029 ],
4030 "Initial state before deleting middle file"
4031 );
4032
4033 submit_deletion(&panel, cx);
4034 assert_eq!(
4035 visible_entries_as_strings(&panel, 0..15, cx),
4036 &[
4037 "v root",
4038 " v dir1",
4039 " > subdir1",
4040 " file2.txt <== selected",
4041 " v dir2",
4042 " > subdir2",
4043 " file3.txt",
4044 " file4.txt",
4045 " file5.txt",
4046 " file6.txt",
4047 ],
4048 "Should select next file after deleting middle file"
4049 );
4050
4051 // Test Case 2: Delete last file in directory
4052 submit_deletion(&panel, cx);
4053 assert_eq!(
4054 visible_entries_as_strings(&panel, 0..15, cx),
4055 &[
4056 "v root",
4057 " v dir1",
4058 " > subdir1 <== selected",
4059 " v dir2",
4060 " > subdir2",
4061 " file3.txt",
4062 " file4.txt",
4063 " file5.txt",
4064 " file6.txt",
4065 ],
4066 "Should select next directory when last file is deleted"
4067 );
4068
4069 // Test Case 3: Delete root level file
4070 select_path(&panel, "root/file6.txt", cx);
4071 assert_eq!(
4072 visible_entries_as_strings(&panel, 0..15, cx),
4073 &[
4074 "v root",
4075 " v dir1",
4076 " > subdir1",
4077 " v dir2",
4078 " > subdir2",
4079 " file3.txt",
4080 " file4.txt",
4081 " file5.txt",
4082 " file6.txt <== selected",
4083 ],
4084 "Initial state before deleting root level file"
4085 );
4086
4087 submit_deletion(&panel, cx);
4088 assert_eq!(
4089 visible_entries_as_strings(&panel, 0..15, cx),
4090 &[
4091 "v root",
4092 " v dir1",
4093 " > subdir1",
4094 " v dir2",
4095 " > subdir2",
4096 " file3.txt",
4097 " file4.txt",
4098 " file5.txt <== selected",
4099 ],
4100 "Should select prev entry at root level"
4101 );
4102}
4103
4104#[gpui::test]
4105async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
4106 init_test_with_editor(cx);
4107
4108 let fs = FakeFs::new(cx.executor());
4109 fs.insert_tree(
4110 path!("/root"),
4111 json!({
4112 "aa": "// Testing 1",
4113 "bb": "// Testing 2",
4114 "cc": "// Testing 3",
4115 "dd": "// Testing 4",
4116 "ee": "// Testing 5",
4117 "ff": "// Testing 6",
4118 "gg": "// Testing 7",
4119 "hh": "// Testing 8",
4120 "ii": "// Testing 8",
4121 ".gitignore": "bb\ndd\nee\nff\nii\n'",
4122 }),
4123 )
4124 .await;
4125
4126 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4127 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4128 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4129
4130 // Test 1: Auto selection with one gitignored file next to the deleted file
4131 cx.update(|_, cx| {
4132 let settings = *ProjectPanelSettings::get_global(cx);
4133 ProjectPanelSettings::override_global(
4134 ProjectPanelSettings {
4135 hide_gitignore: true,
4136 ..settings
4137 },
4138 cx,
4139 );
4140 });
4141
4142 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4143
4144 select_path(&panel, "root/aa", cx);
4145 assert_eq!(
4146 visible_entries_as_strings(&panel, 0..10, cx),
4147 &[
4148 "v root",
4149 " .gitignore",
4150 " aa <== selected",
4151 " cc",
4152 " gg",
4153 " hh"
4154 ],
4155 "Initial state should hide files on .gitignore"
4156 );
4157
4158 submit_deletion(&panel, cx);
4159
4160 assert_eq!(
4161 visible_entries_as_strings(&panel, 0..10, cx),
4162 &[
4163 "v root",
4164 " .gitignore",
4165 " cc <== selected",
4166 " gg",
4167 " hh"
4168 ],
4169 "Should select next entry not on .gitignore"
4170 );
4171
4172 // Test 2: Auto selection with many gitignored files next to the deleted file
4173 submit_deletion(&panel, cx);
4174 assert_eq!(
4175 visible_entries_as_strings(&panel, 0..10, cx),
4176 &[
4177 "v root",
4178 " .gitignore",
4179 " gg <== selected",
4180 " hh"
4181 ],
4182 "Should select next entry not on .gitignore"
4183 );
4184
4185 // Test 3: Auto selection of entry before deleted file
4186 select_path(&panel, "root/hh", cx);
4187 assert_eq!(
4188 visible_entries_as_strings(&panel, 0..10, cx),
4189 &[
4190 "v root",
4191 " .gitignore",
4192 " gg",
4193 " hh <== selected"
4194 ],
4195 "Should select next entry not on .gitignore"
4196 );
4197 submit_deletion(&panel, cx);
4198 assert_eq!(
4199 visible_entries_as_strings(&panel, 0..10, cx),
4200 &["v root", " .gitignore", " gg <== selected"],
4201 "Should select next entry not on .gitignore"
4202 );
4203}
4204
4205#[gpui::test]
4206async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
4207 init_test_with_editor(cx);
4208
4209 let fs = FakeFs::new(cx.executor());
4210 fs.insert_tree(
4211 path!("/root"),
4212 json!({
4213 "dir1": {
4214 "file1": "// Testing",
4215 "file2": "// Testing",
4216 "file3": "// Testing"
4217 },
4218 "aa": "// Testing",
4219 ".gitignore": "file1\nfile3\n",
4220 }),
4221 )
4222 .await;
4223
4224 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4225 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4226 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4227
4228 cx.update(|_, cx| {
4229 let settings = *ProjectPanelSettings::get_global(cx);
4230 ProjectPanelSettings::override_global(
4231 ProjectPanelSettings {
4232 hide_gitignore: true,
4233 ..settings
4234 },
4235 cx,
4236 );
4237 });
4238
4239 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4240
4241 // Test 1: Visible items should exclude files on gitignore
4242 toggle_expand_dir(&panel, "root/dir1", cx);
4243 select_path(&panel, "root/dir1/file2", cx);
4244 assert_eq!(
4245 visible_entries_as_strings(&panel, 0..10, cx),
4246 &[
4247 "v root",
4248 " v dir1",
4249 " file2 <== selected",
4250 " .gitignore",
4251 " aa"
4252 ],
4253 "Initial state should hide files on .gitignore"
4254 );
4255 submit_deletion(&panel, cx);
4256
4257 // Test 2: Auto selection should go to the parent
4258 assert_eq!(
4259 visible_entries_as_strings(&panel, 0..10, cx),
4260 &[
4261 "v root",
4262 " v dir1 <== selected",
4263 " .gitignore",
4264 " aa"
4265 ],
4266 "Initial state should hide files on .gitignore"
4267 );
4268}
4269
4270#[gpui::test]
4271async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
4272 init_test_with_editor(cx);
4273
4274 let fs = FakeFs::new(cx.executor());
4275 fs.insert_tree(
4276 "/root",
4277 json!({
4278 "dir1": {
4279 "subdir1": {
4280 "a.txt": "",
4281 "b.txt": ""
4282 },
4283 "file1.txt": "",
4284 },
4285 "dir2": {
4286 "subdir2": {
4287 "c.txt": "",
4288 "d.txt": ""
4289 },
4290 "file2.txt": "",
4291 },
4292 "file3.txt": "",
4293 }),
4294 )
4295 .await;
4296
4297 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4298 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4299 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4300 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4301
4302 toggle_expand_dir(&panel, "root/dir1", cx);
4303 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4304 toggle_expand_dir(&panel, "root/dir2", cx);
4305 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4306
4307 // Test Case 1: Select and delete nested directory with parent
4308 cx.simulate_modifiers_change(gpui::Modifiers {
4309 control: true,
4310 ..Default::default()
4311 });
4312 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4313 select_path_with_mark(&panel, "root/dir1", cx);
4314
4315 assert_eq!(
4316 visible_entries_as_strings(&panel, 0..15, cx),
4317 &[
4318 "v root",
4319 " v dir1 <== selected <== marked",
4320 " v subdir1 <== marked",
4321 " a.txt",
4322 " b.txt",
4323 " file1.txt",
4324 " v dir2",
4325 " v subdir2",
4326 " c.txt",
4327 " d.txt",
4328 " file2.txt",
4329 " file3.txt",
4330 ],
4331 "Initial state before deleting nested directory with parent"
4332 );
4333
4334 submit_deletion(&panel, cx);
4335 assert_eq!(
4336 visible_entries_as_strings(&panel, 0..15, cx),
4337 &[
4338 "v root",
4339 " v dir2 <== selected",
4340 " v subdir2",
4341 " c.txt",
4342 " d.txt",
4343 " file2.txt",
4344 " file3.txt",
4345 ],
4346 "Should select next directory after deleting directory with parent"
4347 );
4348
4349 // Test Case 2: Select mixed files and directories across levels
4350 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4351 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4352 select_path_with_mark(&panel, "root/file3.txt", cx);
4353
4354 assert_eq!(
4355 visible_entries_as_strings(&panel, 0..15, cx),
4356 &[
4357 "v root",
4358 " v dir2",
4359 " v subdir2",
4360 " c.txt <== marked",
4361 " d.txt",
4362 " file2.txt <== marked",
4363 " file3.txt <== selected <== marked",
4364 ],
4365 "Initial state before deleting"
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 dir2 <== selected",
4374 " v subdir2",
4375 " d.txt",
4376 ],
4377 "Should select sibling directory"
4378 );
4379}
4380
4381#[gpui::test]
4382async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4383 init_test_with_editor(cx);
4384
4385 let fs = FakeFs::new(cx.executor());
4386 fs.insert_tree(
4387 "/root",
4388 json!({
4389 "dir1": {
4390 "subdir1": {
4391 "a.txt": "",
4392 "b.txt": ""
4393 },
4394 "file1.txt": "",
4395 },
4396 "dir2": {
4397 "subdir2": {
4398 "c.txt": "",
4399 "d.txt": ""
4400 },
4401 "file2.txt": "",
4402 },
4403 "file3.txt": "",
4404 "file4.txt": "",
4405 }),
4406 )
4407 .await;
4408
4409 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4410 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4411 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4412 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4413
4414 toggle_expand_dir(&panel, "root/dir1", cx);
4415 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4416 toggle_expand_dir(&panel, "root/dir2", cx);
4417 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4418
4419 // Test Case 1: Select all root files and directories
4420 cx.simulate_modifiers_change(gpui::Modifiers {
4421 control: true,
4422 ..Default::default()
4423 });
4424 select_path_with_mark(&panel, "root/dir1", cx);
4425 select_path_with_mark(&panel, "root/dir2", cx);
4426 select_path_with_mark(&panel, "root/file3.txt", cx);
4427 select_path_with_mark(&panel, "root/file4.txt", cx);
4428 assert_eq!(
4429 visible_entries_as_strings(&panel, 0..20, cx),
4430 &[
4431 "v root",
4432 " v dir1 <== marked",
4433 " v subdir1",
4434 " a.txt",
4435 " b.txt",
4436 " file1.txt",
4437 " v dir2 <== marked",
4438 " v subdir2",
4439 " c.txt",
4440 " d.txt",
4441 " file2.txt",
4442 " file3.txt <== marked",
4443 " file4.txt <== selected <== marked",
4444 ],
4445 "State before deleting all contents"
4446 );
4447
4448 submit_deletion(&panel, cx);
4449 assert_eq!(
4450 visible_entries_as_strings(&panel, 0..20, cx),
4451 &["v root <== selected"],
4452 "Only empty root directory should remain after deleting all contents"
4453 );
4454}
4455
4456#[gpui::test]
4457async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4458 init_test_with_editor(cx);
4459
4460 let fs = FakeFs::new(cx.executor());
4461 fs.insert_tree(
4462 "/root",
4463 json!({
4464 "dir1": {
4465 "subdir1": {
4466 "file_a.txt": "content a",
4467 "file_b.txt": "content b",
4468 },
4469 "subdir2": {
4470 "file_c.txt": "content c",
4471 },
4472 "file1.txt": "content 1",
4473 },
4474 "dir2": {
4475 "file2.txt": "content 2",
4476 },
4477 }),
4478 )
4479 .await;
4480
4481 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4482 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4483 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4484 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4485
4486 toggle_expand_dir(&panel, "root/dir1", cx);
4487 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4488 toggle_expand_dir(&panel, "root/dir2", cx);
4489 cx.simulate_modifiers_change(gpui::Modifiers {
4490 control: true,
4491 ..Default::default()
4492 });
4493
4494 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4495 select_path_with_mark(&panel, "root/dir1", cx);
4496 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4497 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4498
4499 assert_eq!(
4500 visible_entries_as_strings(&panel, 0..20, cx),
4501 &[
4502 "v root",
4503 " v dir1 <== marked",
4504 " v subdir1 <== marked",
4505 " file_a.txt <== selected <== marked",
4506 " file_b.txt",
4507 " > subdir2",
4508 " file1.txt",
4509 " v dir2",
4510 " file2.txt",
4511 ],
4512 "State with parent dir, subdir, and file selected"
4513 );
4514 submit_deletion(&panel, cx);
4515 assert_eq!(
4516 visible_entries_as_strings(&panel, 0..20, cx),
4517 &["v root", " v dir2 <== selected", " file2.txt",],
4518 "Only dir2 should remain after deletion"
4519 );
4520}
4521
4522#[gpui::test]
4523async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4524 init_test_with_editor(cx);
4525
4526 let fs = FakeFs::new(cx.executor());
4527 // First worktree
4528 fs.insert_tree(
4529 "/root1",
4530 json!({
4531 "dir1": {
4532 "file1.txt": "content 1",
4533 "file2.txt": "content 2",
4534 },
4535 "dir2": {
4536 "file3.txt": "content 3",
4537 },
4538 }),
4539 )
4540 .await;
4541
4542 // Second worktree
4543 fs.insert_tree(
4544 "/root2",
4545 json!({
4546 "dir3": {
4547 "file4.txt": "content 4",
4548 "file5.txt": "content 5",
4549 },
4550 "file6.txt": "content 6",
4551 }),
4552 )
4553 .await;
4554
4555 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4556 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4557 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4558 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4559
4560 // Expand all directories for testing
4561 toggle_expand_dir(&panel, "root1/dir1", cx);
4562 toggle_expand_dir(&panel, "root1/dir2", cx);
4563 toggle_expand_dir(&panel, "root2/dir3", cx);
4564
4565 // Test Case 1: Delete files across different worktrees
4566 cx.simulate_modifiers_change(gpui::Modifiers {
4567 control: true,
4568 ..Default::default()
4569 });
4570 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4571 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4572
4573 assert_eq!(
4574 visible_entries_as_strings(&panel, 0..20, cx),
4575 &[
4576 "v root1",
4577 " v dir1",
4578 " file1.txt <== marked",
4579 " file2.txt",
4580 " v dir2",
4581 " file3.txt",
4582 "v root2",
4583 " v dir3",
4584 " file4.txt <== selected <== marked",
4585 " file5.txt",
4586 " file6.txt",
4587 ],
4588 "Initial state with files selected from different worktrees"
4589 );
4590
4591 submit_deletion(&panel, cx);
4592 assert_eq!(
4593 visible_entries_as_strings(&panel, 0..20, cx),
4594 &[
4595 "v root1",
4596 " v dir1",
4597 " file2.txt",
4598 " v dir2",
4599 " file3.txt",
4600 "v root2",
4601 " v dir3",
4602 " file5.txt <== selected",
4603 " file6.txt",
4604 ],
4605 "Should select next file in the last worktree after deletion"
4606 );
4607
4608 // Test Case 2: Delete directories from different worktrees
4609 select_path_with_mark(&panel, "root1/dir1", cx);
4610 select_path_with_mark(&panel, "root2/dir3", cx);
4611
4612 assert_eq!(
4613 visible_entries_as_strings(&panel, 0..20, cx),
4614 &[
4615 "v root1",
4616 " v dir1 <== marked",
4617 " file2.txt",
4618 " v dir2",
4619 " file3.txt",
4620 "v root2",
4621 " v dir3 <== selected <== marked",
4622 " file5.txt",
4623 " file6.txt",
4624 ],
4625 "State with directories marked from different worktrees"
4626 );
4627
4628 submit_deletion(&panel, cx);
4629 assert_eq!(
4630 visible_entries_as_strings(&panel, 0..20, cx),
4631 &[
4632 "v root1",
4633 " v dir2",
4634 " file3.txt",
4635 "v root2",
4636 " file6.txt <== selected",
4637 ],
4638 "Should select remaining file in last worktree after directory deletion"
4639 );
4640
4641 // Test Case 4: Delete all remaining files except roots
4642 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4643 select_path_with_mark(&panel, "root2/file6.txt", cx);
4644
4645 assert_eq!(
4646 visible_entries_as_strings(&panel, 0..20, cx),
4647 &[
4648 "v root1",
4649 " v dir2",
4650 " file3.txt <== marked",
4651 "v root2",
4652 " file6.txt <== selected <== marked",
4653 ],
4654 "State with all remaining files marked"
4655 );
4656
4657 submit_deletion(&panel, cx);
4658 assert_eq!(
4659 visible_entries_as_strings(&panel, 0..20, cx),
4660 &["v root1", " v dir2", "v root2 <== selected"],
4661 "Second parent root should be selected after deleting"
4662 );
4663}
4664
4665#[gpui::test]
4666async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4667 init_test_with_editor(cx);
4668
4669 let fs = FakeFs::new(cx.executor());
4670 fs.insert_tree(
4671 "/root",
4672 json!({
4673 "dir1": {
4674 "file1.txt": "",
4675 "file2.txt": "",
4676 "file3.txt": "",
4677 },
4678 "dir2": {
4679 "file4.txt": "",
4680 "file5.txt": "",
4681 },
4682 }),
4683 )
4684 .await;
4685
4686 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4687 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4688 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4689 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4690
4691 toggle_expand_dir(&panel, "root/dir1", cx);
4692 toggle_expand_dir(&panel, "root/dir2", cx);
4693
4694 cx.simulate_modifiers_change(gpui::Modifiers {
4695 control: true,
4696 ..Default::default()
4697 });
4698
4699 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4700 select_path(&panel, "root/dir1/file1.txt", cx);
4701
4702 assert_eq!(
4703 visible_entries_as_strings(&panel, 0..15, cx),
4704 &[
4705 "v root",
4706 " v dir1",
4707 " file1.txt <== selected",
4708 " file2.txt <== marked",
4709 " file3.txt",
4710 " v dir2",
4711 " file4.txt",
4712 " file5.txt",
4713 ],
4714 "Initial state with one marked entry and different selection"
4715 );
4716
4717 // Delete should operate on the selected entry (file1.txt)
4718 submit_deletion(&panel, cx);
4719 assert_eq!(
4720 visible_entries_as_strings(&panel, 0..15, cx),
4721 &[
4722 "v root",
4723 " v dir1",
4724 " file2.txt <== selected <== marked",
4725 " file3.txt",
4726 " v dir2",
4727 " file4.txt",
4728 " file5.txt",
4729 ],
4730 "Should delete selected file, not marked file"
4731 );
4732
4733 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
4734 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
4735 select_path(&panel, "root/dir2/file5.txt", cx);
4736
4737 assert_eq!(
4738 visible_entries_as_strings(&panel, 0..15, cx),
4739 &[
4740 "v root",
4741 " v dir1",
4742 " file2.txt <== marked",
4743 " file3.txt <== marked",
4744 " v dir2",
4745 " file4.txt <== marked",
4746 " file5.txt <== selected",
4747 ],
4748 "Initial state with multiple marked entries and different selection"
4749 );
4750
4751 // Delete should operate on all marked entries, ignoring the selection
4752 submit_deletion(&panel, cx);
4753 assert_eq!(
4754 visible_entries_as_strings(&panel, 0..15, cx),
4755 &[
4756 "v root",
4757 " v dir1",
4758 " v dir2",
4759 " file5.txt <== selected",
4760 ],
4761 "Should delete all marked files, leaving only the selected file"
4762 );
4763}
4764
4765#[gpui::test]
4766async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
4767 init_test_with_editor(cx);
4768
4769 let fs = FakeFs::new(cx.executor());
4770 fs.insert_tree(
4771 "/root_b",
4772 json!({
4773 "dir1": {
4774 "file1.txt": "content 1",
4775 "file2.txt": "content 2",
4776 },
4777 }),
4778 )
4779 .await;
4780
4781 fs.insert_tree(
4782 "/root_c",
4783 json!({
4784 "dir2": {},
4785 }),
4786 )
4787 .await;
4788
4789 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
4790 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4791 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4792 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4793
4794 toggle_expand_dir(&panel, "root_b/dir1", cx);
4795 toggle_expand_dir(&panel, "root_c/dir2", cx);
4796
4797 cx.simulate_modifiers_change(gpui::Modifiers {
4798 control: true,
4799 ..Default::default()
4800 });
4801 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
4802 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
4803
4804 assert_eq!(
4805 visible_entries_as_strings(&panel, 0..20, cx),
4806 &[
4807 "v root_b",
4808 " v dir1",
4809 " file1.txt <== marked",
4810 " file2.txt <== selected <== marked",
4811 "v root_c",
4812 " v dir2",
4813 ],
4814 "Initial state with files marked in root_b"
4815 );
4816
4817 submit_deletion(&panel, cx);
4818 assert_eq!(
4819 visible_entries_as_strings(&panel, 0..20, cx),
4820 &[
4821 "v root_b",
4822 " v dir1 <== selected",
4823 "v root_c",
4824 " v dir2",
4825 ],
4826 "After deletion in root_b as it's last deletion, selection should be in root_b"
4827 );
4828
4829 select_path_with_mark(&panel, "root_c/dir2", cx);
4830
4831 submit_deletion(&panel, cx);
4832 assert_eq!(
4833 visible_entries_as_strings(&panel, 0..20, cx),
4834 &["v root_b", " v dir1", "v root_c <== selected",],
4835 "After deleting from root_c, it should remain in root_c"
4836 );
4837}
4838
4839fn toggle_expand_dir(
4840 panel: &Entity<ProjectPanel>,
4841 path: impl AsRef<Path>,
4842 cx: &mut VisualTestContext,
4843) {
4844 let path = path.as_ref();
4845 panel.update_in(cx, |panel, window, cx| {
4846 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
4847 let worktree = worktree.read(cx);
4848 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4849 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4850 panel.toggle_expanded(entry_id, window, cx);
4851 return;
4852 }
4853 }
4854 panic!("no worktree for path {:?}", path);
4855 });
4856}
4857
4858#[gpui::test]
4859async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
4860 init_test_with_editor(cx);
4861
4862 let fs = FakeFs::new(cx.executor());
4863 fs.insert_tree(
4864 path!("/root"),
4865 json!({
4866 ".gitignore": "**/ignored_dir\n**/ignored_nested",
4867 "dir1": {
4868 "empty1": {
4869 "empty2": {
4870 "empty3": {
4871 "file.txt": ""
4872 }
4873 }
4874 },
4875 "subdir1": {
4876 "file1.txt": "",
4877 "file2.txt": "",
4878 "ignored_nested": {
4879 "ignored_file.txt": ""
4880 }
4881 },
4882 "ignored_dir": {
4883 "subdir": {
4884 "deep_file.txt": ""
4885 }
4886 }
4887 }
4888 }),
4889 )
4890 .await;
4891
4892 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4893 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4894 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4895
4896 // Test 1: When auto-fold is enabled
4897 cx.update(|_, cx| {
4898 let settings = *ProjectPanelSettings::get_global(cx);
4899 ProjectPanelSettings::override_global(
4900 ProjectPanelSettings {
4901 auto_fold_dirs: true,
4902 ..settings
4903 },
4904 cx,
4905 );
4906 });
4907
4908 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4909
4910 assert_eq!(
4911 visible_entries_as_strings(&panel, 0..20, cx),
4912 &["v root", " > dir1", " .gitignore",],
4913 "Initial state should show collapsed root structure"
4914 );
4915
4916 toggle_expand_dir(&panel, "root/dir1", cx);
4917 assert_eq!(
4918 visible_entries_as_strings(&panel, 0..20, cx),
4919 &[
4920 "v root",
4921 " v dir1 <== selected",
4922 " > empty1/empty2/empty3",
4923 " > ignored_dir",
4924 " > subdir1",
4925 " .gitignore",
4926 ],
4927 "Should show first level with auto-folded dirs and ignored dir visible"
4928 );
4929
4930 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4931 panel.update(cx, |panel, cx| {
4932 let project = panel.project.read(cx);
4933 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4934 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4935 panel.update_visible_entries(None, cx);
4936 });
4937 cx.run_until_parked();
4938
4939 assert_eq!(
4940 visible_entries_as_strings(&panel, 0..20, cx),
4941 &[
4942 "v root",
4943 " v dir1 <== selected",
4944 " v empty1",
4945 " v empty2",
4946 " v empty3",
4947 " file.txt",
4948 " > ignored_dir",
4949 " v subdir1",
4950 " > ignored_nested",
4951 " file1.txt",
4952 " file2.txt",
4953 " .gitignore",
4954 ],
4955 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
4956 );
4957
4958 // Test 2: When auto-fold is disabled
4959 cx.update(|_, cx| {
4960 let settings = *ProjectPanelSettings::get_global(cx);
4961 ProjectPanelSettings::override_global(
4962 ProjectPanelSettings {
4963 auto_fold_dirs: false,
4964 ..settings
4965 },
4966 cx,
4967 );
4968 });
4969
4970 panel.update_in(cx, |panel, window, cx| {
4971 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
4972 });
4973
4974 toggle_expand_dir(&panel, "root/dir1", cx);
4975 assert_eq!(
4976 visible_entries_as_strings(&panel, 0..20, cx),
4977 &[
4978 "v root",
4979 " v dir1 <== selected",
4980 " > empty1",
4981 " > ignored_dir",
4982 " > subdir1",
4983 " .gitignore",
4984 ],
4985 "With auto-fold disabled: should show all directories separately"
4986 );
4987
4988 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
4989 panel.update(cx, |panel, cx| {
4990 let project = panel.project.read(cx);
4991 let worktree = project.worktrees(cx).next().unwrap().read(cx);
4992 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
4993 panel.update_visible_entries(None, cx);
4994 });
4995 cx.run_until_parked();
4996
4997 assert_eq!(
4998 visible_entries_as_strings(&panel, 0..20, cx),
4999 &[
5000 "v root",
5001 " v dir1 <== selected",
5002 " v empty1",
5003 " v empty2",
5004 " v empty3",
5005 " file.txt",
5006 " > ignored_dir",
5007 " v subdir1",
5008 " > ignored_nested",
5009 " file1.txt",
5010 " file2.txt",
5011 " .gitignore",
5012 ],
5013 "After expand_all without auto-fold: should expand all dirs normally, \
5014 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
5015 );
5016
5017 // Test 3: When explicitly called on ignored directory
5018 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
5019 panel.update(cx, |panel, cx| {
5020 let project = panel.project.read(cx);
5021 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5022 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
5023 panel.update_visible_entries(None, cx);
5024 });
5025 cx.run_until_parked();
5026
5027 assert_eq!(
5028 visible_entries_as_strings(&panel, 0..20, cx),
5029 &[
5030 "v root",
5031 " v dir1 <== selected",
5032 " v empty1",
5033 " v empty2",
5034 " v empty3",
5035 " file.txt",
5036 " v ignored_dir",
5037 " v subdir",
5038 " deep_file.txt",
5039 " v subdir1",
5040 " > ignored_nested",
5041 " file1.txt",
5042 " file2.txt",
5043 " .gitignore",
5044 ],
5045 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
5046 );
5047}
5048
5049#[gpui::test]
5050async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
5051 init_test(cx);
5052
5053 let fs = FakeFs::new(cx.executor());
5054 fs.insert_tree(
5055 path!("/root"),
5056 json!({
5057 "dir1": {
5058 "subdir1": {
5059 "nested1": {
5060 "file1.txt": "",
5061 "file2.txt": ""
5062 },
5063 },
5064 "subdir2": {
5065 "file4.txt": ""
5066 }
5067 },
5068 "dir2": {
5069 "single_file": {
5070 "file5.txt": ""
5071 }
5072 }
5073 }),
5074 )
5075 .await;
5076
5077 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5078 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5079 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5080
5081 // Test 1: Basic collapsing
5082 {
5083 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5084
5085 toggle_expand_dir(&panel, "root/dir1", cx);
5086 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5087 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5088 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
5089
5090 assert_eq!(
5091 visible_entries_as_strings(&panel, 0..20, cx),
5092 &[
5093 "v root",
5094 " v dir1",
5095 " v subdir1",
5096 " v nested1",
5097 " file1.txt",
5098 " file2.txt",
5099 " v subdir2 <== selected",
5100 " file4.txt",
5101 " > dir2",
5102 ],
5103 "Initial state with everything expanded"
5104 );
5105
5106 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5107 panel.update(cx, |panel, cx| {
5108 let project = panel.project.read(cx);
5109 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5110 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5111 panel.update_visible_entries(None, cx);
5112 });
5113
5114 assert_eq!(
5115 visible_entries_as_strings(&panel, 0..20, cx),
5116 &["v root", " > dir1", " > dir2",],
5117 "All subdirs under dir1 should be collapsed"
5118 );
5119 }
5120
5121 // Test 2: With auto-fold enabled
5122 {
5123 cx.update(|_, cx| {
5124 let settings = *ProjectPanelSettings::get_global(cx);
5125 ProjectPanelSettings::override_global(
5126 ProjectPanelSettings {
5127 auto_fold_dirs: true,
5128 ..settings
5129 },
5130 cx,
5131 );
5132 });
5133
5134 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5135
5136 toggle_expand_dir(&panel, "root/dir1", cx);
5137 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5138 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5139
5140 assert_eq!(
5141 visible_entries_as_strings(&panel, 0..20, cx),
5142 &[
5143 "v root",
5144 " v dir1",
5145 " v subdir1/nested1 <== selected",
5146 " file1.txt",
5147 " file2.txt",
5148 " > subdir2",
5149 " > dir2/single_file",
5150 ],
5151 "Initial state with some dirs expanded"
5152 );
5153
5154 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5155 panel.update(cx, |panel, cx| {
5156 let project = panel.project.read(cx);
5157 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5158 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5159 });
5160
5161 toggle_expand_dir(&panel, "root/dir1", cx);
5162
5163 assert_eq!(
5164 visible_entries_as_strings(&panel, 0..20, cx),
5165 &[
5166 "v root",
5167 " v dir1 <== selected",
5168 " > subdir1/nested1",
5169 " > subdir2",
5170 " > dir2/single_file",
5171 ],
5172 "Subdirs should be collapsed and folded with auto-fold enabled"
5173 );
5174 }
5175
5176 // Test 3: With auto-fold disabled
5177 {
5178 cx.update(|_, cx| {
5179 let settings = *ProjectPanelSettings::get_global(cx);
5180 ProjectPanelSettings::override_global(
5181 ProjectPanelSettings {
5182 auto_fold_dirs: false,
5183 ..settings
5184 },
5185 cx,
5186 );
5187 });
5188
5189 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5190
5191 toggle_expand_dir(&panel, "root/dir1", cx);
5192 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5193 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5194
5195 assert_eq!(
5196 visible_entries_as_strings(&panel, 0..20, cx),
5197 &[
5198 "v root",
5199 " v dir1",
5200 " v subdir1",
5201 " v nested1 <== selected",
5202 " file1.txt",
5203 " file2.txt",
5204 " > subdir2",
5205 " > dir2",
5206 ],
5207 "Initial state with some dirs expanded and auto-fold disabled"
5208 );
5209
5210 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5211 panel.update(cx, |panel, cx| {
5212 let project = panel.project.read(cx);
5213 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5214 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5215 });
5216
5217 toggle_expand_dir(&panel, "root/dir1", cx);
5218
5219 assert_eq!(
5220 visible_entries_as_strings(&panel, 0..20, cx),
5221 &[
5222 "v root",
5223 " v dir1 <== selected",
5224 " > subdir1",
5225 " > subdir2",
5226 " > dir2",
5227 ],
5228 "Subdirs should be collapsed but not folded with auto-fold disabled"
5229 );
5230 }
5231}
5232
5233#[gpui::test]
5234async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
5235 init_test(cx);
5236
5237 let fs = FakeFs::new(cx.executor());
5238 fs.insert_tree(
5239 path!("/root"),
5240 json!({
5241 "dir1": {
5242 "file1.txt": "",
5243 },
5244 }),
5245 )
5246 .await;
5247
5248 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5249 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5250 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5251
5252 let panel = workspace
5253 .update(cx, |workspace, window, cx| {
5254 let panel = ProjectPanel::new(workspace, window, cx);
5255 workspace.add_panel(panel.clone(), window, cx);
5256 panel
5257 })
5258 .unwrap();
5259
5260 #[rustfmt::skip]
5261 assert_eq!(
5262 visible_entries_as_strings(&panel, 0..20, cx),
5263 &[
5264 "v root",
5265 " > dir1",
5266 ],
5267 "Initial state with nothing selected"
5268 );
5269
5270 panel.update_in(cx, |panel, window, cx| {
5271 panel.new_file(&NewFile, window, cx);
5272 });
5273 panel.update_in(cx, |panel, window, cx| {
5274 assert!(panel.filename_editor.read(cx).is_focused(window));
5275 });
5276 panel
5277 .update_in(cx, |panel, window, cx| {
5278 panel.filename_editor.update(cx, |editor, cx| {
5279 editor.set_text("hello_from_no_selections", window, cx)
5280 });
5281 panel.confirm_edit(window, cx).unwrap()
5282 })
5283 .await
5284 .unwrap();
5285
5286 #[rustfmt::skip]
5287 assert_eq!(
5288 visible_entries_as_strings(&panel, 0..20, cx),
5289 &[
5290 "v root",
5291 " > dir1",
5292 " hello_from_no_selections <== selected <== marked",
5293 ],
5294 "A new file is created under the root directory"
5295 );
5296}
5297
5298#[gpui::test]
5299async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
5300 init_test(cx);
5301
5302 let fs = FakeFs::new(cx.executor());
5303 fs.insert_tree(
5304 path!("/root"),
5305 json!({
5306 "existing_dir": {
5307 "existing_file.txt": "",
5308 },
5309 "existing_file.txt": "",
5310 }),
5311 )
5312 .await;
5313
5314 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5315 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5316 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5317
5318 cx.update(|_, cx| {
5319 let settings = *ProjectPanelSettings::get_global(cx);
5320 ProjectPanelSettings::override_global(
5321 ProjectPanelSettings {
5322 hide_root: true,
5323 ..settings
5324 },
5325 cx,
5326 );
5327 });
5328
5329 let panel = workspace
5330 .update(cx, |workspace, window, cx| {
5331 let panel = ProjectPanel::new(workspace, window, cx);
5332 workspace.add_panel(panel.clone(), window, cx);
5333 panel
5334 })
5335 .unwrap();
5336
5337 #[rustfmt::skip]
5338 assert_eq!(
5339 visible_entries_as_strings(&panel, 0..20, cx),
5340 &[
5341 "> existing_dir",
5342 " existing_file.txt",
5343 ],
5344 "Initial state with hide_root=true, root should be hidden and nothing selected"
5345 );
5346
5347 panel.update(cx, |panel, _| {
5348 assert!(
5349 panel.selection.is_none(),
5350 "Should have no selection initially"
5351 );
5352 });
5353
5354 // Test 1: Create new file when no entry is selected
5355 panel.update_in(cx, |panel, window, cx| {
5356 panel.new_file(&NewFile, window, cx);
5357 });
5358 panel.update_in(cx, |panel, window, cx| {
5359 assert!(panel.filename_editor.read(cx).is_focused(window));
5360 });
5361
5362 #[rustfmt::skip]
5363 assert_eq!(
5364 visible_entries_as_strings(&panel, 0..20, cx),
5365 &[
5366 "> existing_dir",
5367 " [EDITOR: ''] <== selected",
5368 " existing_file.txt",
5369 ],
5370 "Editor should appear at root level when hide_root=true and no selection"
5371 );
5372
5373 let confirm = panel.update_in(cx, |panel, window, cx| {
5374 panel.filename_editor.update(cx, |editor, cx| {
5375 editor.set_text("new_file_at_root.txt", window, cx)
5376 });
5377 panel.confirm_edit(window, cx).unwrap()
5378 });
5379 confirm.await.unwrap();
5380
5381 #[rustfmt::skip]
5382 assert_eq!(
5383 visible_entries_as_strings(&panel, 0..20, cx),
5384 &[
5385 "> existing_dir",
5386 " existing_file.txt",
5387 " new_file_at_root.txt <== selected <== marked",
5388 ],
5389 "New file should be created at root level and visible without root prefix"
5390 );
5391
5392 assert!(
5393 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
5394 "File should be created in the actual root directory"
5395 );
5396
5397 // Test 2: Create new directory when no entry is selected
5398 panel.update(cx, |panel, _| {
5399 panel.selection = None;
5400 });
5401
5402 panel.update_in(cx, |panel, window, cx| {
5403 panel.new_directory(&NewDirectory, window, cx);
5404 });
5405 panel.update_in(cx, |panel, window, cx| {
5406 assert!(panel.filename_editor.read(cx).is_focused(window));
5407 });
5408
5409 #[rustfmt::skip]
5410 assert_eq!(
5411 visible_entries_as_strings(&panel, 0..20, cx),
5412 &[
5413 "> [EDITOR: ''] <== selected",
5414 "> existing_dir",
5415 " existing_file.txt",
5416 " new_file_at_root.txt",
5417 ],
5418 "Directory editor should appear at root level when hide_root=true and no selection"
5419 );
5420
5421 let confirm = panel.update_in(cx, |panel, window, cx| {
5422 panel.filename_editor.update(cx, |editor, cx| {
5423 editor.set_text("new_dir_at_root", window, cx)
5424 });
5425 panel.confirm_edit(window, cx).unwrap()
5426 });
5427 confirm.await.unwrap();
5428
5429 #[rustfmt::skip]
5430 assert_eq!(
5431 visible_entries_as_strings(&panel, 0..20, cx),
5432 &[
5433 "> existing_dir",
5434 "v new_dir_at_root <== selected",
5435 " existing_file.txt",
5436 " new_file_at_root.txt",
5437 ],
5438 "New directory should be created at root level and visible without root prefix"
5439 );
5440
5441 assert!(
5442 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
5443 "Directory should be created in the actual root directory"
5444 );
5445}
5446
5447#[gpui::test]
5448async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
5449 init_test(cx);
5450
5451 let fs = FakeFs::new(cx.executor());
5452 fs.insert_tree(
5453 "/root",
5454 json!({
5455 "dir1": {
5456 "file1.txt": "",
5457 "dir2": {
5458 "file2.txt": ""
5459 }
5460 },
5461 "file3.txt": ""
5462 }),
5463 )
5464 .await;
5465
5466 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5467 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5468 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5469 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5470
5471 panel.update(cx, |panel, cx| {
5472 let project = panel.project.read(cx);
5473 let worktree = project.visible_worktrees(cx).next().unwrap();
5474 let worktree = worktree.read(cx);
5475
5476 // Test 1: Target is a directory, should highlight the directory itself
5477 let dir_entry = worktree.entry_for_path("dir1").unwrap();
5478 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
5479 assert_eq!(
5480 result,
5481 Some(dir_entry.id),
5482 "Should highlight directory itself"
5483 );
5484
5485 // Test 2: Target is nested file, should highlight immediate parent
5486 let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
5487 let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
5488 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
5489 assert_eq!(
5490 result,
5491 Some(nested_parent.id),
5492 "Should highlight immediate parent"
5493 );
5494
5495 // Test 3: Target is root level file, should highlight root
5496 let root_file = worktree.entry_for_path("file3.txt").unwrap();
5497 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
5498 assert_eq!(
5499 result,
5500 Some(worktree.root_entry().unwrap().id),
5501 "Root level file should return None"
5502 );
5503
5504 // Test 4: Target is root itself, should highlight root
5505 let root_entry = worktree.root_entry().unwrap();
5506 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
5507 assert_eq!(
5508 result,
5509 Some(root_entry.id),
5510 "Root level file should return None"
5511 );
5512 });
5513}
5514
5515#[gpui::test]
5516async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
5517 init_test(cx);
5518
5519 let fs = FakeFs::new(cx.executor());
5520 fs.insert_tree(
5521 "/root",
5522 json!({
5523 "parent_dir": {
5524 "child_file.txt": "",
5525 "sibling_file.txt": "",
5526 "child_dir": {
5527 "nested_file.txt": ""
5528 }
5529 },
5530 "other_dir": {
5531 "other_file.txt": ""
5532 }
5533 }),
5534 )
5535 .await;
5536
5537 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5538 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5539 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5540 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5541
5542 panel.update(cx, |panel, cx| {
5543 let project = panel.project.read(cx);
5544 let worktree = project.visible_worktrees(cx).next().unwrap();
5545 let worktree_id = worktree.read(cx).id();
5546 let worktree = worktree.read(cx);
5547
5548 let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
5549 let child_file = worktree
5550 .entry_for_path("parent_dir/child_file.txt")
5551 .unwrap();
5552 let sibling_file = worktree
5553 .entry_for_path("parent_dir/sibling_file.txt")
5554 .unwrap();
5555 let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
5556 let other_dir = worktree.entry_for_path("other_dir").unwrap();
5557 let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
5558
5559 // Test 1: Single item drag, don't highlight parent directory
5560 let dragged_selection = DraggedSelection {
5561 active_selection: SelectedEntry {
5562 worktree_id,
5563 entry_id: child_file.id,
5564 },
5565 marked_selections: Arc::new([SelectedEntry {
5566 worktree_id,
5567 entry_id: child_file.id,
5568 }]),
5569 };
5570 let result =
5571 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5572 assert_eq!(result, None, "Should not highlight parent of dragged item");
5573
5574 // Test 2: Single item drag, don't highlight sibling files
5575 let result = panel.highlight_entry_for_selection_drag(
5576 sibling_file,
5577 worktree,
5578 &dragged_selection,
5579 cx,
5580 );
5581 assert_eq!(result, None, "Should not highlight sibling files");
5582
5583 // Test 3: Single item drag, highlight unrelated directory
5584 let result =
5585 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
5586 assert_eq!(
5587 result,
5588 Some(other_dir.id),
5589 "Should highlight unrelated directory"
5590 );
5591
5592 // Test 4: Single item drag, highlight sibling directory
5593 let result =
5594 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5595 assert_eq!(
5596 result,
5597 Some(child_dir.id),
5598 "Should highlight sibling directory"
5599 );
5600
5601 // Test 5: Multiple items drag, highlight parent directory
5602 let dragged_selection = DraggedSelection {
5603 active_selection: SelectedEntry {
5604 worktree_id,
5605 entry_id: child_file.id,
5606 },
5607 marked_selections: Arc::new([
5608 SelectedEntry {
5609 worktree_id,
5610 entry_id: child_file.id,
5611 },
5612 SelectedEntry {
5613 worktree_id,
5614 entry_id: sibling_file.id,
5615 },
5616 ]),
5617 };
5618 let result =
5619 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5620 assert_eq!(
5621 result,
5622 Some(parent_dir.id),
5623 "Should highlight parent with multiple items"
5624 );
5625
5626 // Test 6: Target is file in different directory, highlight parent
5627 let result =
5628 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
5629 assert_eq!(
5630 result,
5631 Some(other_dir.id),
5632 "Should highlight parent of target file"
5633 );
5634
5635 // Test 7: Target is directory, always highlight
5636 let result =
5637 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5638 assert_eq!(
5639 result,
5640 Some(child_dir.id),
5641 "Should always highlight directories"
5642 );
5643 });
5644}
5645
5646#[gpui::test]
5647async fn test_hide_root(cx: &mut gpui::TestAppContext) {
5648 init_test(cx);
5649
5650 let fs = FakeFs::new(cx.executor());
5651 fs.insert_tree(
5652 "/root1",
5653 json!({
5654 "dir1": {
5655 "file1.txt": "content",
5656 "file2.txt": "content",
5657 },
5658 "dir2": {
5659 "file3.txt": "content",
5660 },
5661 "file4.txt": "content",
5662 }),
5663 )
5664 .await;
5665
5666 fs.insert_tree(
5667 "/root2",
5668 json!({
5669 "dir3": {
5670 "file5.txt": "content",
5671 },
5672 "file6.txt": "content",
5673 }),
5674 )
5675 .await;
5676
5677 // Test 1: Single worktree with hide_root = false
5678 {
5679 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5680 let workspace =
5681 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5682 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5683
5684 cx.update(|_, cx| {
5685 let settings = *ProjectPanelSettings::get_global(cx);
5686 ProjectPanelSettings::override_global(
5687 ProjectPanelSettings {
5688 hide_root: false,
5689 ..settings
5690 },
5691 cx,
5692 );
5693 });
5694
5695 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5696
5697 #[rustfmt::skip]
5698 assert_eq!(
5699 visible_entries_as_strings(&panel, 0..10, cx),
5700 &[
5701 "v root1",
5702 " > dir1",
5703 " > dir2",
5704 " file4.txt",
5705 ],
5706 "With hide_root=false and single worktree, root should be visible"
5707 );
5708 }
5709
5710 // Test 2: Single worktree with hide_root = true
5711 {
5712 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5713 let workspace =
5714 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5715 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5716
5717 // Set hide_root to true
5718 cx.update(|_, cx| {
5719 let settings = *ProjectPanelSettings::get_global(cx);
5720 ProjectPanelSettings::override_global(
5721 ProjectPanelSettings {
5722 hide_root: true,
5723 ..settings
5724 },
5725 cx,
5726 );
5727 });
5728
5729 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5730
5731 assert_eq!(
5732 visible_entries_as_strings(&panel, 0..10, cx),
5733 &["> dir1", "> dir2", " file4.txt",],
5734 "With hide_root=true and single worktree, root should be hidden"
5735 );
5736
5737 // Test expanding directories still works without root
5738 toggle_expand_dir(&panel, "root1/dir1", cx);
5739 assert_eq!(
5740 visible_entries_as_strings(&panel, 0..10, cx),
5741 &[
5742 "v dir1 <== selected",
5743 " file1.txt",
5744 " file2.txt",
5745 "> dir2",
5746 " file4.txt",
5747 ],
5748 "Should be able to expand directories even when root is hidden"
5749 );
5750 }
5751
5752 // Test 3: Multiple worktrees with hide_root = true
5753 {
5754 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5755 let workspace =
5756 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5757 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5758
5759 // Set hide_root to true
5760 cx.update(|_, cx| {
5761 let settings = *ProjectPanelSettings::get_global(cx);
5762 ProjectPanelSettings::override_global(
5763 ProjectPanelSettings {
5764 hide_root: true,
5765 ..settings
5766 },
5767 cx,
5768 );
5769 });
5770
5771 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5772
5773 assert_eq!(
5774 visible_entries_as_strings(&panel, 0..10, cx),
5775 &[
5776 "v root1",
5777 " > dir1",
5778 " > dir2",
5779 " file4.txt",
5780 "v root2",
5781 " > dir3",
5782 " file6.txt",
5783 ],
5784 "With hide_root=true and multiple worktrees, roots should still be visible"
5785 );
5786 }
5787
5788 // Test 4: Multiple worktrees with hide_root = false
5789 {
5790 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5791 let workspace =
5792 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5793 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5794
5795 cx.update(|_, cx| {
5796 let settings = *ProjectPanelSettings::get_global(cx);
5797 ProjectPanelSettings::override_global(
5798 ProjectPanelSettings {
5799 hide_root: false,
5800 ..settings
5801 },
5802 cx,
5803 );
5804 });
5805
5806 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5807
5808 assert_eq!(
5809 visible_entries_as_strings(&panel, 0..10, cx),
5810 &[
5811 "v root1",
5812 " > dir1",
5813 " > dir2",
5814 " file4.txt",
5815 "v root2",
5816 " > dir3",
5817 " file6.txt",
5818 ],
5819 "With hide_root=false and multiple worktrees, roots should be visible"
5820 );
5821 }
5822}
5823
5824#[gpui::test]
5825async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
5826 init_test_with_editor(cx);
5827
5828 let fs = FakeFs::new(cx.executor());
5829 fs.insert_tree(
5830 "/root",
5831 json!({
5832 "file1.txt": "content of file1",
5833 "file2.txt": "content of file2",
5834 "dir1": {
5835 "file3.txt": "content of file3"
5836 }
5837 }),
5838 )
5839 .await;
5840
5841 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5842 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5843 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5844 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5845
5846 let file1_path = path!("root/file1.txt");
5847 let file2_path = path!("root/file2.txt");
5848 select_path_with_mark(&panel, file1_path, cx);
5849 select_path_with_mark(&panel, file2_path, cx);
5850
5851 panel.update_in(cx, |panel, window, cx| {
5852 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
5853 });
5854 cx.executor().run_until_parked();
5855
5856 workspace
5857 .update(cx, |workspace, _, cx| {
5858 let active_items = workspace
5859 .panes()
5860 .iter()
5861 .filter_map(|pane| pane.read(cx).active_item())
5862 .collect::<Vec<_>>();
5863 assert_eq!(active_items.len(), 1);
5864 let diff_view = active_items
5865 .into_iter()
5866 .next()
5867 .unwrap()
5868 .downcast::<FileDiffView>()
5869 .expect("Open item should be an FileDiffView");
5870 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
5871 assert_eq!(
5872 diff_view.tab_tooltip_text(cx).unwrap(),
5873 format!("{} ↔ {}", file1_path, file2_path)
5874 );
5875 })
5876 .unwrap();
5877
5878 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
5879 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
5880 let worktree_id = panel.update(cx, |panel, cx| {
5881 panel
5882 .project
5883 .read(cx)
5884 .worktrees(cx)
5885 .next()
5886 .unwrap()
5887 .read(cx)
5888 .id()
5889 });
5890
5891 let expected_entries = [
5892 SelectedEntry {
5893 worktree_id,
5894 entry_id: file1_entry_id,
5895 },
5896 SelectedEntry {
5897 worktree_id,
5898 entry_id: file2_entry_id,
5899 },
5900 ];
5901 panel.update(cx, |panel, _cx| {
5902 assert_eq!(
5903 &panel.marked_entries, &expected_entries,
5904 "Should keep marked entries after comparison"
5905 );
5906 });
5907
5908 panel.update(cx, |panel, cx| {
5909 panel.project.update(cx, |_, cx| {
5910 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
5911 })
5912 });
5913
5914 panel.update(cx, |panel, _cx| {
5915 assert_eq!(
5916 &panel.marked_entries, &expected_entries,
5917 "Marked entries should persist after focusing back on the project panel"
5918 );
5919 });
5920}
5921
5922#[gpui::test]
5923async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
5924 init_test_with_editor(cx);
5925
5926 let fs = FakeFs::new(cx.executor());
5927 fs.insert_tree(
5928 "/root",
5929 json!({
5930 "file1.txt": "content of file1",
5931 "file2.txt": "content of file2",
5932 "dir1": {},
5933 "dir2": {
5934 "file3.txt": "content of file3"
5935 }
5936 }),
5937 )
5938 .await;
5939
5940 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5941 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5942 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5943 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5944
5945 // Test 1: When only one file is selected, there should be no compare option
5946 select_path(&panel, "root/file1.txt", cx);
5947
5948 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
5949 assert_eq!(
5950 selected_files, None,
5951 "Should not have compare option when only one file is selected"
5952 );
5953
5954 // Test 2: When multiple files are selected, there should be a compare option
5955 select_path_with_mark(&panel, "root/file1.txt", cx);
5956 select_path_with_mark(&panel, "root/file2.txt", cx);
5957
5958 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
5959 assert!(
5960 selected_files.is_some(),
5961 "Should have files selected for comparison"
5962 );
5963 if let Some((file1, file2)) = selected_files {
5964 assert!(
5965 file1.to_string_lossy().ends_with("file1.txt")
5966 && file2.to_string_lossy().ends_with("file2.txt"),
5967 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
5968 );
5969 }
5970
5971 // Test 3: Selecting a directory shouldn't count as a comparable file
5972 select_path_with_mark(&panel, "root/dir1", cx);
5973
5974 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
5975 assert!(
5976 selected_files.is_some(),
5977 "Directory selection should not affect comparable files"
5978 );
5979 if let Some((file1, file2)) = selected_files {
5980 assert!(
5981 file1.to_string_lossy().ends_with("file1.txt")
5982 && file2.to_string_lossy().ends_with("file2.txt"),
5983 "Selecting a directory should not affect the number of comparable files"
5984 );
5985 }
5986
5987 // Test 4: Selecting one more file
5988 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
5989
5990 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
5991 assert!(
5992 selected_files.is_some(),
5993 "Directory selection should not affect comparable files"
5994 );
5995 if let Some((file1, file2)) = selected_files {
5996 assert!(
5997 file1.to_string_lossy().ends_with("file2.txt")
5998 && file2.to_string_lossy().ends_with("file3.txt"),
5999 "Selecting a directory should not affect the number of comparable files"
6000 );
6001 }
6002}
6003
6004fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
6005 let path = path.as_ref();
6006 panel.update(cx, |panel, cx| {
6007 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6008 let worktree = worktree.read(cx);
6009 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6010 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6011 panel.selection = Some(crate::SelectedEntry {
6012 worktree_id: worktree.id(),
6013 entry_id,
6014 });
6015 return;
6016 }
6017 }
6018 panic!("no worktree for path {:?}", path);
6019 });
6020}
6021
6022fn select_path_with_mark(
6023 panel: &Entity<ProjectPanel>,
6024 path: impl AsRef<Path>,
6025 cx: &mut VisualTestContext,
6026) {
6027 let path = path.as_ref();
6028 panel.update(cx, |panel, cx| {
6029 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6030 let worktree = worktree.read(cx);
6031 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6032 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6033 let entry = crate::SelectedEntry {
6034 worktree_id: worktree.id(),
6035 entry_id,
6036 };
6037 if !panel.marked_entries.contains(&entry) {
6038 panel.marked_entries.push(entry);
6039 }
6040 panel.selection = Some(entry);
6041 return;
6042 }
6043 }
6044 panic!("no worktree for path {:?}", path);
6045 });
6046}
6047
6048fn find_project_entry(
6049 panel: &Entity<ProjectPanel>,
6050 path: impl AsRef<Path>,
6051 cx: &mut VisualTestContext,
6052) -> Option<ProjectEntryId> {
6053 let path = path.as_ref();
6054 panel.update(cx, |panel, cx| {
6055 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6056 let worktree = worktree.read(cx);
6057 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6058 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6059 }
6060 }
6061 panic!("no worktree for path {path:?}");
6062 })
6063}
6064
6065fn visible_entries_as_strings(
6066 panel: &Entity<ProjectPanel>,
6067 range: Range<usize>,
6068 cx: &mut VisualTestContext,
6069) -> Vec<String> {
6070 let mut result = Vec::new();
6071 let mut project_entries = HashSet::default();
6072 let mut has_editor = false;
6073
6074 panel.update_in(cx, |panel, window, cx| {
6075 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
6076 if details.is_editing {
6077 assert!(!has_editor, "duplicate editor entry");
6078 has_editor = true;
6079 } else {
6080 assert!(
6081 project_entries.insert(project_entry),
6082 "duplicate project entry {:?} {:?}",
6083 project_entry,
6084 details
6085 );
6086 }
6087
6088 let indent = " ".repeat(details.depth);
6089 let icon = if details.kind.is_dir() {
6090 if details.is_expanded { "v " } else { "> " }
6091 } else {
6092 " "
6093 };
6094 #[cfg(windows)]
6095 let filename = details.filename.replace("\\", "/");
6096 #[cfg(not(windows))]
6097 let filename = details.filename;
6098 let name = if details.is_editing {
6099 format!("[EDITOR: '{}']", filename)
6100 } else if details.is_processing {
6101 format!("[PROCESSING: '{}']", filename)
6102 } else {
6103 filename
6104 };
6105 let selected = if details.is_selected {
6106 " <== selected"
6107 } else {
6108 ""
6109 };
6110 let marked = if details.is_marked {
6111 " <== marked"
6112 } else {
6113 ""
6114 };
6115
6116 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6117 });
6118 });
6119
6120 result
6121}
6122
6123fn init_test(cx: &mut TestAppContext) {
6124 cx.update(|cx| {
6125 let settings_store = SettingsStore::test(cx);
6126 cx.set_global(settings_store);
6127 init_settings(cx);
6128 theme::init(theme::LoadThemes::JustBase, cx);
6129 language::init(cx);
6130 editor::init_settings(cx);
6131 crate::init(cx);
6132 workspace::init_settings(cx);
6133 client::init_settings(cx);
6134 Project::init_settings(cx);
6135
6136 cx.update_global::<SettingsStore, _>(|store, cx| {
6137 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6138 project_panel_settings.auto_fold_dirs = Some(false);
6139 });
6140 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6141 worktree_settings.file_scan_exclusions = Some(Vec::new());
6142 });
6143 });
6144 });
6145}
6146
6147fn init_test_with_editor(cx: &mut TestAppContext) {
6148 cx.update(|cx| {
6149 let app_state = AppState::test(cx);
6150 theme::init(theme::LoadThemes::JustBase, cx);
6151 init_settings(cx);
6152 language::init(cx);
6153 editor::init(cx);
6154 crate::init(cx);
6155 workspace::init(app_state, cx);
6156 Project::init_settings(cx);
6157
6158 cx.update_global::<SettingsStore, _>(|store, cx| {
6159 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6160 project_panel_settings.auto_fold_dirs = Some(false);
6161 });
6162 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6163 worktree_settings.file_scan_exclusions = Some(Vec::new());
6164 });
6165 });
6166 });
6167}
6168
6169fn ensure_single_file_is_opened(
6170 window: &WindowHandle<Workspace>,
6171 expected_path: &str,
6172 cx: &mut TestAppContext,
6173) {
6174 window
6175 .update(cx, |workspace, _, cx| {
6176 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6177 assert_eq!(worktrees.len(), 1);
6178 let worktree_id = worktrees[0].read(cx).id();
6179
6180 let open_project_paths = workspace
6181 .panes()
6182 .iter()
6183 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6184 .collect::<Vec<_>>();
6185 assert_eq!(
6186 open_project_paths,
6187 vec![ProjectPath {
6188 worktree_id,
6189 path: Arc::from(Path::new(expected_path))
6190 }],
6191 "Should have opened file, selected in project panel"
6192 );
6193 })
6194 .unwrap();
6195}
6196
6197fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6198 assert!(
6199 !cx.has_pending_prompt(),
6200 "Should have no prompts before the deletion"
6201 );
6202 panel.update_in(cx, |panel, window, cx| {
6203 panel.delete(&Delete { skip_prompt: false }, window, cx)
6204 });
6205 assert!(
6206 cx.has_pending_prompt(),
6207 "Should have a prompt after the deletion"
6208 );
6209 cx.simulate_prompt_answer("Delete");
6210 assert!(
6211 !cx.has_pending_prompt(),
6212 "Should have no prompts after prompt was replied to"
6213 );
6214 cx.executor().run_until_parked();
6215}
6216
6217fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6218 assert!(
6219 !cx.has_pending_prompt(),
6220 "Should have no prompts before the deletion"
6221 );
6222 panel.update_in(cx, |panel, window, cx| {
6223 panel.delete(&Delete { skip_prompt: true }, window, cx)
6224 });
6225 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
6226 cx.executor().run_until_parked();
6227}
6228
6229fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
6230 assert!(
6231 !cx.has_pending_prompt(),
6232 "Should have no prompts after deletion operation closes the file"
6233 );
6234 workspace
6235 .read_with(cx, |workspace, cx| {
6236 let open_project_paths = workspace
6237 .panes()
6238 .iter()
6239 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6240 .collect::<Vec<_>>();
6241 assert!(
6242 open_project_paths.is_empty(),
6243 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
6244 );
6245 })
6246 .unwrap();
6247}
6248
6249struct TestProjectItemView {
6250 focus_handle: FocusHandle,
6251 path: ProjectPath,
6252}
6253
6254struct TestProjectItem {
6255 path: ProjectPath,
6256}
6257
6258impl project::ProjectItem for TestProjectItem {
6259 fn try_open(
6260 _project: &Entity<Project>,
6261 path: &ProjectPath,
6262 cx: &mut App,
6263 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
6264 let path = path.clone();
6265 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
6266 }
6267
6268 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
6269 None
6270 }
6271
6272 fn project_path(&self, _: &App) -> Option<ProjectPath> {
6273 Some(self.path.clone())
6274 }
6275
6276 fn is_dirty(&self) -> bool {
6277 false
6278 }
6279}
6280
6281impl ProjectItem for TestProjectItemView {
6282 type Item = TestProjectItem;
6283
6284 fn for_project_item(
6285 _: Entity<Project>,
6286 _: Option<&Pane>,
6287 project_item: Entity<Self::Item>,
6288 _: &mut Window,
6289 cx: &mut Context<Self>,
6290 ) -> Self
6291 where
6292 Self: Sized,
6293 {
6294 Self {
6295 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
6296 focus_handle: cx.focus_handle(),
6297 }
6298 }
6299}
6300
6301impl Item for TestProjectItemView {
6302 type Event = ();
6303
6304 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
6305 "Test".into()
6306 }
6307}
6308
6309impl EventEmitter<()> for TestProjectItemView {}
6310
6311impl Focusable for TestProjectItemView {
6312 fn focus_handle(&self, _: &App) -> FocusHandle {
6313 self.focus_handle.clone()
6314 }
6315}
6316
6317impl Render for TestProjectItemView {
6318 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
6319 Empty
6320 }
6321}