1use super::*;
2use collections::HashSet;
3use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
4use pretty_assertions::assert_eq;
5use project::FakeFs;
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(cx, |settings| {
165 settings.project.worktree.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
3166#[gpui::test]
3167async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
3168 init_test(cx);
3169
3170 let fs = FakeFs::new(cx.executor());
3171 fs.insert_tree(
3172 "/root",
3173 json!({
3174 "a": {
3175 "b": {
3176 "c": {
3177 "d": {}
3178 }
3179 }
3180 },
3181 "target_destination": {}
3182 }),
3183 )
3184 .await;
3185
3186 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3187 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3188 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3189
3190 cx.update(|_, cx| {
3191 let settings = *ProjectPanelSettings::get_global(cx);
3192 ProjectPanelSettings::override_global(
3193 ProjectPanelSettings {
3194 auto_fold_dirs: true,
3195 ..settings
3196 },
3197 cx,
3198 );
3199 });
3200
3201 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3202
3203 // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
3204 select_path(&panel, "root/a/b/c/d", cx);
3205 panel.update_in(cx, |panel, window, cx| {
3206 let drag = DraggedSelection {
3207 active_selection: SelectedEntry {
3208 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3209 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3210 },
3211 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3212 };
3213 let target_entry = panel
3214 .project
3215 .read(cx)
3216 .visible_worktrees(cx)
3217 .next()
3218 .unwrap()
3219 .read(cx)
3220 .entry_for_path("target_destination")
3221 .unwrap();
3222 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3223 });
3224 cx.executor().run_until_parked();
3225
3226 assert_eq!(
3227 visible_entries_as_strings(&panel, 0..10, cx),
3228 &[
3229 "v root",
3230 " > a/b/c",
3231 " > target_destination/d <== selected"
3232 ],
3233 "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
3234 );
3235
3236 // Reset
3237 select_path(&panel, "root/target_destination/d", cx);
3238 panel.update_in(cx, |panel, window, cx| {
3239 let drag = DraggedSelection {
3240 active_selection: SelectedEntry {
3241 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3242 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3243 },
3244 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3245 };
3246 let target_entry = panel
3247 .project
3248 .read(cx)
3249 .visible_worktrees(cx)
3250 .next()
3251 .unwrap()
3252 .read(cx)
3253 .entry_for_path("a/b/c")
3254 .unwrap();
3255 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3256 });
3257 cx.executor().run_until_parked();
3258
3259 // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
3260 select_path(&panel, "root/a/b", cx);
3261 panel.update_in(cx, |panel, window, cx| {
3262 let drag = DraggedSelection {
3263 active_selection: SelectedEntry {
3264 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3265 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3266 },
3267 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3268 };
3269 let target_entry = panel
3270 .project
3271 .read(cx)
3272 .visible_worktrees(cx)
3273 .next()
3274 .unwrap()
3275 .read(cx)
3276 .entry_for_path("target_destination")
3277 .unwrap();
3278 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3279 });
3280 cx.executor().run_until_parked();
3281
3282 assert_eq!(
3283 visible_entries_as_strings(&panel, 0..10, cx),
3284 &["v root", " v a", " > target_destination/b/c/d"],
3285 "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
3286 );
3287
3288 // Reset
3289 select_path(&panel, "root/target_destination/b", cx);
3290 panel.update_in(cx, |panel, window, cx| {
3291 let drag = DraggedSelection {
3292 active_selection: SelectedEntry {
3293 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3294 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3295 },
3296 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3297 };
3298 let target_entry = panel
3299 .project
3300 .read(cx)
3301 .visible_worktrees(cx)
3302 .next()
3303 .unwrap()
3304 .read(cx)
3305 .entry_for_path("a")
3306 .unwrap();
3307 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3308 });
3309 cx.executor().run_until_parked();
3310
3311 // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
3312 select_path(&panel, "root/a", cx);
3313 panel.update_in(cx, |panel, window, cx| {
3314 let drag = DraggedSelection {
3315 active_selection: SelectedEntry {
3316 worktree_id: panel.selection.as_ref().unwrap().worktree_id,
3317 entry_id: panel.resolve_entry(panel.selection.as_ref().unwrap().entry_id),
3318 },
3319 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
3320 };
3321 let target_entry = panel
3322 .project
3323 .read(cx)
3324 .visible_worktrees(cx)
3325 .next()
3326 .unwrap()
3327 .read(cx)
3328 .entry_for_path("target_destination")
3329 .unwrap();
3330 panel.drag_onto(&drag, target_entry.id, false, window, cx);
3331 });
3332 cx.executor().run_until_parked();
3333
3334 assert_eq!(
3335 visible_entries_as_strings(&panel, 0..10, cx),
3336 &["v root", " > target_destination/a/b/c/d"],
3337 "Moving first directory 'a' should move whole 'a/b/c/d' chain"
3338 );
3339}
3340
3341#[gpui::test]
3342async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3343 init_test_with_editor(cx);
3344 cx.update(|cx| {
3345 cx.update_global::<SettingsStore, _>(|store, cx| {
3346 store.update_user_settings(cx, |settings| {
3347 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3348 settings
3349 .project_panel
3350 .get_or_insert_default()
3351 .auto_reveal_entries = Some(false);
3352 });
3353 })
3354 });
3355
3356 let fs = FakeFs::new(cx.background_executor.clone());
3357 fs.insert_tree(
3358 "/project_root",
3359 json!({
3360 ".git": {},
3361 ".gitignore": "**/gitignored_dir",
3362 "dir_1": {
3363 "file_1.py": "# File 1_1 contents",
3364 "file_2.py": "# File 1_2 contents",
3365 "file_3.py": "# File 1_3 contents",
3366 "gitignored_dir": {
3367 "file_a.py": "# File contents",
3368 "file_b.py": "# File contents",
3369 "file_c.py": "# File contents",
3370 },
3371 },
3372 "dir_2": {
3373 "file_1.py": "# File 2_1 contents",
3374 "file_2.py": "# File 2_2 contents",
3375 "file_3.py": "# File 2_3 contents",
3376 }
3377 }),
3378 )
3379 .await;
3380
3381 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3382 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3383 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3384 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3385
3386 assert_eq!(
3387 visible_entries_as_strings(&panel, 0..20, cx),
3388 &[
3389 "v project_root",
3390 " > .git",
3391 " > dir_1",
3392 " > dir_2",
3393 " .gitignore",
3394 ]
3395 );
3396
3397 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3398 .expect("dir 1 file is not ignored and should have an entry");
3399 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3400 .expect("dir 2 file is not ignored and should have an entry");
3401 let gitignored_dir_file =
3402 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3403 assert_eq!(
3404 gitignored_dir_file, None,
3405 "File in the gitignored dir should not have an entry before its dir is toggled"
3406 );
3407
3408 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3409 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3410 cx.executor().run_until_parked();
3411 assert_eq!(
3412 visible_entries_as_strings(&panel, 0..20, cx),
3413 &[
3414 "v project_root",
3415 " > .git",
3416 " v dir_1",
3417 " v gitignored_dir <== selected",
3418 " file_a.py",
3419 " file_b.py",
3420 " file_c.py",
3421 " file_1.py",
3422 " file_2.py",
3423 " file_3.py",
3424 " > dir_2",
3425 " .gitignore",
3426 ],
3427 "Should show gitignored dir file list in the project panel"
3428 );
3429 let gitignored_dir_file =
3430 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3431 .expect("after gitignored dir got opened, a file entry should be present");
3432
3433 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3434 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3435 assert_eq!(
3436 visible_entries_as_strings(&panel, 0..20, cx),
3437 &[
3438 "v project_root",
3439 " > .git",
3440 " > dir_1 <== selected",
3441 " > dir_2",
3442 " .gitignore",
3443 ],
3444 "Should hide all dir contents again and prepare for the auto reveal test"
3445 );
3446
3447 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3448 panel.update(cx, |panel, cx| {
3449 panel.project.update(cx, |_, cx| {
3450 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3451 })
3452 });
3453 cx.run_until_parked();
3454 assert_eq!(
3455 visible_entries_as_strings(&panel, 0..20, cx),
3456 &[
3457 "v project_root",
3458 " > .git",
3459 " > dir_1 <== selected",
3460 " > dir_2",
3461 " .gitignore",
3462 ],
3463 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3464 );
3465 }
3466
3467 cx.update(|_, cx| {
3468 cx.update_global::<SettingsStore, _>(|store, cx| {
3469 store.update_user_settings(cx, |settings| {
3470 settings
3471 .project_panel
3472 .get_or_insert_default()
3473 .auto_reveal_entries = Some(true)
3474 });
3475 })
3476 });
3477
3478 panel.update(cx, |panel, cx| {
3479 panel.project.update(cx, |_, cx| {
3480 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3481 })
3482 });
3483 cx.run_until_parked();
3484 assert_eq!(
3485 visible_entries_as_strings(&panel, 0..20, cx),
3486 &[
3487 "v project_root",
3488 " > .git",
3489 " v dir_1",
3490 " > gitignored_dir",
3491 " file_1.py <== selected <== marked",
3492 " file_2.py",
3493 " file_3.py",
3494 " > dir_2",
3495 " .gitignore",
3496 ],
3497 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3498 );
3499
3500 panel.update(cx, |panel, cx| {
3501 panel.project.update(cx, |_, cx| {
3502 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3503 })
3504 });
3505 cx.run_until_parked();
3506 assert_eq!(
3507 visible_entries_as_strings(&panel, 0..20, cx),
3508 &[
3509 "v project_root",
3510 " > .git",
3511 " v dir_1",
3512 " > gitignored_dir",
3513 " file_1.py",
3514 " file_2.py",
3515 " file_3.py",
3516 " v dir_2",
3517 " file_1.py <== selected <== marked",
3518 " file_2.py",
3519 " file_3.py",
3520 " .gitignore",
3521 ],
3522 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3523 );
3524
3525 panel.update(cx, |panel, cx| {
3526 panel.project.update(cx, |_, cx| {
3527 cx.emit(project::Event::ActiveEntryChanged(Some(
3528 gitignored_dir_file,
3529 )))
3530 })
3531 });
3532 cx.run_until_parked();
3533 assert_eq!(
3534 visible_entries_as_strings(&panel, 0..20, cx),
3535 &[
3536 "v project_root",
3537 " > .git",
3538 " v dir_1",
3539 " > gitignored_dir",
3540 " file_1.py",
3541 " file_2.py",
3542 " file_3.py",
3543 " v dir_2",
3544 " file_1.py <== selected <== marked",
3545 " file_2.py",
3546 " file_3.py",
3547 " .gitignore",
3548 ],
3549 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3550 );
3551
3552 panel.update(cx, |panel, cx| {
3553 panel.project.update(cx, |_, cx| {
3554 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3555 })
3556 });
3557 cx.run_until_parked();
3558 assert_eq!(
3559 visible_entries_as_strings(&panel, 0..20, cx),
3560 &[
3561 "v project_root",
3562 " > .git",
3563 " v dir_1",
3564 " v gitignored_dir",
3565 " file_a.py <== selected <== marked",
3566 " file_b.py",
3567 " file_c.py",
3568 " file_1.py",
3569 " file_2.py",
3570 " file_3.py",
3571 " v dir_2",
3572 " file_1.py",
3573 " file_2.py",
3574 " file_3.py",
3575 " .gitignore",
3576 ],
3577 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3578 );
3579}
3580
3581#[gpui::test]
3582async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
3583 init_test_with_editor(cx);
3584 cx.update(|cx| {
3585 cx.update_global::<SettingsStore, _>(|store, cx| {
3586 store.update_user_settings(cx, |settings| {
3587 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3588 settings.project.worktree.file_scan_inclusions =
3589 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
3590 settings
3591 .project_panel
3592 .get_or_insert_default()
3593 .auto_reveal_entries = Some(false)
3594 });
3595 })
3596 });
3597
3598 let fs = FakeFs::new(cx.background_executor.clone());
3599 fs.insert_tree(
3600 "/project_root",
3601 json!({
3602 ".git": {},
3603 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
3604 "dir_1": {
3605 "file_1.py": "# File 1_1 contents",
3606 "file_2.py": "# File 1_2 contents",
3607 "file_3.py": "# File 1_3 contents",
3608 "gitignored_dir": {
3609 "file_a.py": "# File contents",
3610 "file_b.py": "# File contents",
3611 "file_c.py": "# File contents",
3612 },
3613 },
3614 "dir_2": {
3615 "file_1.py": "# File 2_1 contents",
3616 "file_2.py": "# File 2_2 contents",
3617 "file_3.py": "# File 2_3 contents",
3618 },
3619 "always_included_but_ignored_dir": {
3620 "file_a.py": "# File contents",
3621 "file_b.py": "# File contents",
3622 "file_c.py": "# File contents",
3623 },
3624 }),
3625 )
3626 .await;
3627
3628 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3629 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3630 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3631 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3632
3633 assert_eq!(
3634 visible_entries_as_strings(&panel, 0..20, cx),
3635 &[
3636 "v project_root",
3637 " > .git",
3638 " > always_included_but_ignored_dir",
3639 " > dir_1",
3640 " > dir_2",
3641 " .gitignore",
3642 ]
3643 );
3644
3645 let gitignored_dir_file =
3646 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3647 let always_included_but_ignored_dir_file = find_project_entry(
3648 &panel,
3649 "project_root/always_included_but_ignored_dir/file_a.py",
3650 cx,
3651 )
3652 .expect("file that is .gitignored but set to always be included should have an entry");
3653 assert_eq!(
3654 gitignored_dir_file, None,
3655 "File in the gitignored dir should not have an entry unless its directory is toggled"
3656 );
3657
3658 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3659 cx.run_until_parked();
3660 cx.update(|_, cx| {
3661 cx.update_global::<SettingsStore, _>(|store, cx| {
3662 store.update_user_settings(cx, |settings| {
3663 settings
3664 .project_panel
3665 .get_or_insert_default()
3666 .auto_reveal_entries = Some(true)
3667 });
3668 })
3669 });
3670
3671 panel.update(cx, |panel, cx| {
3672 panel.project.update(cx, |_, cx| {
3673 cx.emit(project::Event::ActiveEntryChanged(Some(
3674 always_included_but_ignored_dir_file,
3675 )))
3676 })
3677 });
3678 cx.run_until_parked();
3679
3680 assert_eq!(
3681 visible_entries_as_strings(&panel, 0..20, cx),
3682 &[
3683 "v project_root",
3684 " > .git",
3685 " v always_included_but_ignored_dir",
3686 " file_a.py <== selected <== marked",
3687 " file_b.py",
3688 " file_c.py",
3689 " v dir_1",
3690 " > gitignored_dir",
3691 " file_1.py",
3692 " file_2.py",
3693 " file_3.py",
3694 " > dir_2",
3695 " .gitignore",
3696 ],
3697 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
3698 );
3699}
3700
3701#[gpui::test]
3702async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3703 init_test_with_editor(cx);
3704 cx.update(|cx| {
3705 cx.update_global::<SettingsStore, _>(|store, cx| {
3706 store.update_user_settings(cx, |settings| {
3707 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
3708 settings
3709 .project_panel
3710 .get_or_insert_default()
3711 .auto_reveal_entries = Some(false)
3712 });
3713 })
3714 });
3715
3716 let fs = FakeFs::new(cx.background_executor.clone());
3717 fs.insert_tree(
3718 "/project_root",
3719 json!({
3720 ".git": {},
3721 ".gitignore": "**/gitignored_dir",
3722 "dir_1": {
3723 "file_1.py": "# File 1_1 contents",
3724 "file_2.py": "# File 1_2 contents",
3725 "file_3.py": "# File 1_3 contents",
3726 "gitignored_dir": {
3727 "file_a.py": "# File contents",
3728 "file_b.py": "# File contents",
3729 "file_c.py": "# File contents",
3730 },
3731 },
3732 "dir_2": {
3733 "file_1.py": "# File 2_1 contents",
3734 "file_2.py": "# File 2_2 contents",
3735 "file_3.py": "# File 2_3 contents",
3736 }
3737 }),
3738 )
3739 .await;
3740
3741 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3742 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3743 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3744 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3745
3746 assert_eq!(
3747 visible_entries_as_strings(&panel, 0..20, cx),
3748 &[
3749 "v project_root",
3750 " > .git",
3751 " > dir_1",
3752 " > dir_2",
3753 " .gitignore",
3754 ]
3755 );
3756
3757 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3758 .expect("dir 1 file is not ignored and should have an entry");
3759 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3760 .expect("dir 2 file is not ignored and should have an entry");
3761 let gitignored_dir_file =
3762 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3763 assert_eq!(
3764 gitignored_dir_file, None,
3765 "File in the gitignored dir should not have an entry before its dir is toggled"
3766 );
3767
3768 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3769 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3770 cx.run_until_parked();
3771 assert_eq!(
3772 visible_entries_as_strings(&panel, 0..20, cx),
3773 &[
3774 "v project_root",
3775 " > .git",
3776 " v dir_1",
3777 " v gitignored_dir <== selected",
3778 " file_a.py",
3779 " file_b.py",
3780 " file_c.py",
3781 " file_1.py",
3782 " file_2.py",
3783 " file_3.py",
3784 " > dir_2",
3785 " .gitignore",
3786 ],
3787 "Should show gitignored dir file list in the project panel"
3788 );
3789 let gitignored_dir_file =
3790 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3791 .expect("after gitignored dir got opened, a file entry should be present");
3792
3793 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3794 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3795 assert_eq!(
3796 visible_entries_as_strings(&panel, 0..20, cx),
3797 &[
3798 "v project_root",
3799 " > .git",
3800 " > dir_1 <== selected",
3801 " > dir_2",
3802 " .gitignore",
3803 ],
3804 "Should hide all dir contents again and prepare for the explicit reveal test"
3805 );
3806
3807 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3808 panel.update(cx, |panel, cx| {
3809 panel.project.update(cx, |_, cx| {
3810 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3811 })
3812 });
3813 cx.run_until_parked();
3814 assert_eq!(
3815 visible_entries_as_strings(&panel, 0..20, cx),
3816 &[
3817 "v project_root",
3818 " > .git",
3819 " > dir_1 <== selected",
3820 " > dir_2",
3821 " .gitignore",
3822 ],
3823 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3824 );
3825 }
3826
3827 panel.update(cx, |panel, cx| {
3828 panel.project.update(cx, |_, cx| {
3829 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3830 })
3831 });
3832 cx.run_until_parked();
3833 assert_eq!(
3834 visible_entries_as_strings(&panel, 0..20, cx),
3835 &[
3836 "v project_root",
3837 " > .git",
3838 " v dir_1",
3839 " > gitignored_dir",
3840 " file_1.py <== selected <== marked",
3841 " file_2.py",
3842 " file_3.py",
3843 " > dir_2",
3844 " .gitignore",
3845 ],
3846 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3847 );
3848
3849 panel.update(cx, |panel, cx| {
3850 panel.project.update(cx, |_, cx| {
3851 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3852 })
3853 });
3854 cx.run_until_parked();
3855 assert_eq!(
3856 visible_entries_as_strings(&panel, 0..20, cx),
3857 &[
3858 "v project_root",
3859 " > .git",
3860 " v dir_1",
3861 " > gitignored_dir",
3862 " file_1.py",
3863 " file_2.py",
3864 " file_3.py",
3865 " v dir_2",
3866 " file_1.py <== selected <== marked",
3867 " file_2.py",
3868 " file_3.py",
3869 " .gitignore",
3870 ],
3871 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3872 );
3873
3874 panel.update(cx, |panel, cx| {
3875 panel.project.update(cx, |_, cx| {
3876 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3877 })
3878 });
3879 cx.run_until_parked();
3880 assert_eq!(
3881 visible_entries_as_strings(&panel, 0..20, cx),
3882 &[
3883 "v project_root",
3884 " > .git",
3885 " v dir_1",
3886 " v gitignored_dir",
3887 " file_a.py <== selected <== marked",
3888 " file_b.py",
3889 " file_c.py",
3890 " file_1.py",
3891 " file_2.py",
3892 " file_3.py",
3893 " v dir_2",
3894 " file_1.py",
3895 " file_2.py",
3896 " file_3.py",
3897 " .gitignore",
3898 ],
3899 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3900 );
3901}
3902
3903#[gpui::test]
3904async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
3905 init_test(cx);
3906 cx.update(|cx| {
3907 cx.update_global::<SettingsStore, _>(|store, cx| {
3908 store.update_user_settings(cx, |settings| {
3909 settings.project.worktree.file_scan_exclusions =
3910 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
3911 });
3912 });
3913 });
3914
3915 cx.update(|cx| {
3916 register_project_item::<TestProjectItemView>(cx);
3917 });
3918
3919 let fs = FakeFs::new(cx.executor());
3920 fs.insert_tree(
3921 "/root1",
3922 json!({
3923 ".dockerignore": "",
3924 ".git": {
3925 "HEAD": "",
3926 },
3927 }),
3928 )
3929 .await;
3930
3931 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3932 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3933 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3934 let panel = workspace
3935 .update(cx, |workspace, window, cx| {
3936 let panel = ProjectPanel::new(workspace, window, cx);
3937 workspace.add_panel(panel.clone(), window, cx);
3938 panel
3939 })
3940 .unwrap();
3941
3942 select_path(&panel, "root1", cx);
3943 assert_eq!(
3944 visible_entries_as_strings(&panel, 0..10, cx),
3945 &["v root1 <== selected", " .dockerignore",]
3946 );
3947 workspace
3948 .update(cx, |workspace, _, cx| {
3949 assert!(
3950 workspace.active_item(cx).is_none(),
3951 "Should have no active items in the beginning"
3952 );
3953 })
3954 .unwrap();
3955
3956 let excluded_file_path = ".git/COMMIT_EDITMSG";
3957 let excluded_dir_path = "excluded_dir";
3958
3959 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
3960 panel.update_in(cx, |panel, window, cx| {
3961 assert!(panel.filename_editor.read(cx).is_focused(window));
3962 });
3963 panel
3964 .update_in(cx, |panel, window, cx| {
3965 panel.filename_editor.update(cx, |editor, cx| {
3966 editor.set_text(excluded_file_path, window, cx)
3967 });
3968 panel.confirm_edit(window, cx).unwrap()
3969 })
3970 .await
3971 .unwrap();
3972
3973 assert_eq!(
3974 visible_entries_as_strings(&panel, 0..13, cx),
3975 &["v root1", " .dockerignore"],
3976 "Excluded dir should not be shown after opening a file in it"
3977 );
3978 panel.update_in(cx, |panel, window, cx| {
3979 assert!(
3980 !panel.filename_editor.read(cx).is_focused(window),
3981 "Should have closed the file name editor"
3982 );
3983 });
3984 workspace
3985 .update(cx, |workspace, _, cx| {
3986 let active_entry_path = workspace
3987 .active_item(cx)
3988 .expect("should have opened and activated the excluded item")
3989 .act_as::<TestProjectItemView>(cx)
3990 .expect("should have opened the corresponding project item for the excluded item")
3991 .read(cx)
3992 .path
3993 .clone();
3994 assert_eq!(
3995 active_entry_path.path.as_ref(),
3996 Path::new(excluded_file_path),
3997 "Should open the excluded file"
3998 );
3999
4000 assert!(
4001 workspace.notification_ids().is_empty(),
4002 "Should have no notifications after opening an excluded file"
4003 );
4004 })
4005 .unwrap();
4006 assert!(
4007 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4008 "Should have created the excluded file"
4009 );
4010
4011 select_path(&panel, "root1", cx);
4012 panel.update_in(cx, |panel, window, cx| {
4013 panel.new_directory(&NewDirectory, window, cx)
4014 });
4015 panel.update_in(cx, |panel, window, cx| {
4016 assert!(panel.filename_editor.read(cx).is_focused(window));
4017 });
4018 panel
4019 .update_in(cx, |panel, window, cx| {
4020 panel.filename_editor.update(cx, |editor, cx| {
4021 editor.set_text(excluded_file_path, window, cx)
4022 });
4023 panel.confirm_edit(window, cx).unwrap()
4024 })
4025 .await
4026 .unwrap();
4027
4028 assert_eq!(
4029 visible_entries_as_strings(&panel, 0..13, cx),
4030 &["v root1", " .dockerignore"],
4031 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4032 );
4033 panel.update_in(cx, |panel, window, cx| {
4034 assert!(
4035 !panel.filename_editor.read(cx).is_focused(window),
4036 "Should have closed the file name editor"
4037 );
4038 });
4039 workspace
4040 .update(cx, |workspace, _, cx| {
4041 let notifications = workspace.notification_ids();
4042 assert_eq!(
4043 notifications.len(),
4044 1,
4045 "Should receive one notification with the error message"
4046 );
4047 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4048 assert!(workspace.notification_ids().is_empty());
4049 })
4050 .unwrap();
4051
4052 select_path(&panel, "root1", cx);
4053 panel.update_in(cx, |panel, window, cx| {
4054 panel.new_directory(&NewDirectory, window, cx)
4055 });
4056 panel.update_in(cx, |panel, window, cx| {
4057 assert!(panel.filename_editor.read(cx).is_focused(window));
4058 });
4059 panel
4060 .update_in(cx, |panel, window, cx| {
4061 panel.filename_editor.update(cx, |editor, cx| {
4062 editor.set_text(excluded_dir_path, window, cx)
4063 });
4064 panel.confirm_edit(window, cx).unwrap()
4065 })
4066 .await
4067 .unwrap();
4068
4069 assert_eq!(
4070 visible_entries_as_strings(&panel, 0..13, cx),
4071 &["v root1", " .dockerignore"],
4072 "Should not change the project panel after trying to create an excluded directory"
4073 );
4074 panel.update_in(cx, |panel, window, cx| {
4075 assert!(
4076 !panel.filename_editor.read(cx).is_focused(window),
4077 "Should have closed the file name editor"
4078 );
4079 });
4080 workspace
4081 .update(cx, |workspace, _, cx| {
4082 let notifications = workspace.notification_ids();
4083 assert_eq!(
4084 notifications.len(),
4085 1,
4086 "Should receive one notification explaining that no directory is actually shown"
4087 );
4088 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4089 assert!(workspace.notification_ids().is_empty());
4090 })
4091 .unwrap();
4092 assert!(
4093 fs.is_dir(Path::new("/root1/excluded_dir")).await,
4094 "Should have created the excluded directory"
4095 );
4096}
4097
4098#[gpui::test]
4099async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
4100 init_test_with_editor(cx);
4101
4102 let fs = FakeFs::new(cx.executor());
4103 fs.insert_tree(
4104 "/src",
4105 json!({
4106 "test": {
4107 "first.rs": "// First Rust file",
4108 "second.rs": "// Second Rust file",
4109 "third.rs": "// Third Rust file",
4110 }
4111 }),
4112 )
4113 .await;
4114
4115 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4116 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4117 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4118 let panel = workspace
4119 .update(cx, |workspace, window, cx| {
4120 let panel = ProjectPanel::new(workspace, window, cx);
4121 workspace.add_panel(panel.clone(), window, cx);
4122 panel
4123 })
4124 .unwrap();
4125
4126 select_path(&panel, "src/", cx);
4127 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
4128 cx.executor().run_until_parked();
4129 assert_eq!(
4130 visible_entries_as_strings(&panel, 0..10, cx),
4131 &[
4132 //
4133 "v src <== selected",
4134 " > test"
4135 ]
4136 );
4137 panel.update_in(cx, |panel, window, cx| {
4138 panel.new_directory(&NewDirectory, window, cx)
4139 });
4140 panel.update_in(cx, |panel, window, cx| {
4141 assert!(panel.filename_editor.read(cx).is_focused(window));
4142 });
4143 assert_eq!(
4144 visible_entries_as_strings(&panel, 0..10, cx),
4145 &[
4146 //
4147 "v src",
4148 " > [EDITOR: ''] <== selected",
4149 " > test"
4150 ]
4151 );
4152
4153 panel.update_in(cx, |panel, window, cx| {
4154 panel.cancel(&menu::Cancel, window, cx)
4155 });
4156 assert_eq!(
4157 visible_entries_as_strings(&panel, 0..10, cx),
4158 &[
4159 //
4160 "v src <== selected",
4161 " > test"
4162 ]
4163 );
4164}
4165
4166#[gpui::test]
4167async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
4168 init_test_with_editor(cx);
4169
4170 let fs = FakeFs::new(cx.executor());
4171 fs.insert_tree(
4172 "/root",
4173 json!({
4174 "dir1": {
4175 "subdir1": {},
4176 "file1.txt": "",
4177 "file2.txt": "",
4178 },
4179 "dir2": {
4180 "subdir2": {},
4181 "file3.txt": "",
4182 "file4.txt": "",
4183 },
4184 "file5.txt": "",
4185 "file6.txt": "",
4186 }),
4187 )
4188 .await;
4189
4190 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4191 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4192 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4193 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4194
4195 toggle_expand_dir(&panel, "root/dir1", cx);
4196 toggle_expand_dir(&panel, "root/dir2", cx);
4197
4198 // Test Case 1: Delete middle file in directory
4199 select_path(&panel, "root/dir1/file1.txt", cx);
4200 assert_eq!(
4201 visible_entries_as_strings(&panel, 0..15, cx),
4202 &[
4203 "v root",
4204 " v dir1",
4205 " > subdir1",
4206 " file1.txt <== selected",
4207 " file2.txt",
4208 " v dir2",
4209 " > subdir2",
4210 " file3.txt",
4211 " file4.txt",
4212 " file5.txt",
4213 " file6.txt",
4214 ],
4215 "Initial state before deleting middle file"
4216 );
4217
4218 submit_deletion(&panel, cx);
4219 assert_eq!(
4220 visible_entries_as_strings(&panel, 0..15, cx),
4221 &[
4222 "v root",
4223 " v dir1",
4224 " > subdir1",
4225 " file2.txt <== selected",
4226 " v dir2",
4227 " > subdir2",
4228 " file3.txt",
4229 " file4.txt",
4230 " file5.txt",
4231 " file6.txt",
4232 ],
4233 "Should select next file after deleting middle file"
4234 );
4235
4236 // Test Case 2: Delete last file in directory
4237 submit_deletion(&panel, cx);
4238 assert_eq!(
4239 visible_entries_as_strings(&panel, 0..15, cx),
4240 &[
4241 "v root",
4242 " v dir1",
4243 " > subdir1 <== selected",
4244 " v dir2",
4245 " > subdir2",
4246 " file3.txt",
4247 " file4.txt",
4248 " file5.txt",
4249 " file6.txt",
4250 ],
4251 "Should select next directory when last file is deleted"
4252 );
4253
4254 // Test Case 3: Delete root level file
4255 select_path(&panel, "root/file6.txt", cx);
4256 assert_eq!(
4257 visible_entries_as_strings(&panel, 0..15, cx),
4258 &[
4259 "v root",
4260 " v dir1",
4261 " > subdir1",
4262 " v dir2",
4263 " > subdir2",
4264 " file3.txt",
4265 " file4.txt",
4266 " file5.txt",
4267 " file6.txt <== selected",
4268 ],
4269 "Initial state before deleting root level file"
4270 );
4271
4272 submit_deletion(&panel, cx);
4273 assert_eq!(
4274 visible_entries_as_strings(&panel, 0..15, cx),
4275 &[
4276 "v root",
4277 " v dir1",
4278 " > subdir1",
4279 " v dir2",
4280 " > subdir2",
4281 " file3.txt",
4282 " file4.txt",
4283 " file5.txt <== selected",
4284 ],
4285 "Should select prev entry at root level"
4286 );
4287}
4288
4289#[gpui::test]
4290async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
4291 init_test_with_editor(cx);
4292
4293 let fs = FakeFs::new(cx.executor());
4294 fs.insert_tree(
4295 path!("/root"),
4296 json!({
4297 "aa": "// Testing 1",
4298 "bb": "// Testing 2",
4299 "cc": "// Testing 3",
4300 "dd": "// Testing 4",
4301 "ee": "// Testing 5",
4302 "ff": "// Testing 6",
4303 "gg": "// Testing 7",
4304 "hh": "// Testing 8",
4305 "ii": "// Testing 8",
4306 ".gitignore": "bb\ndd\nee\nff\nii\n'",
4307 }),
4308 )
4309 .await;
4310
4311 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
4312 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4313 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4314
4315 // Test 1: Auto selection with one gitignored file next to the deleted file
4316 cx.update(|_, cx| {
4317 let settings = *ProjectPanelSettings::get_global(cx);
4318 ProjectPanelSettings::override_global(
4319 ProjectPanelSettings {
4320 hide_gitignore: true,
4321 ..settings
4322 },
4323 cx,
4324 );
4325 });
4326
4327 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4328
4329 select_path(&panel, "root/aa", cx);
4330 assert_eq!(
4331 visible_entries_as_strings(&panel, 0..10, cx),
4332 &[
4333 "v root",
4334 " .gitignore",
4335 " aa <== selected",
4336 " cc",
4337 " gg",
4338 " hh"
4339 ],
4340 "Initial state should hide files on .gitignore"
4341 );
4342
4343 submit_deletion(&panel, cx);
4344
4345 assert_eq!(
4346 visible_entries_as_strings(&panel, 0..10, cx),
4347 &[
4348 "v root",
4349 " .gitignore",
4350 " cc <== selected",
4351 " gg",
4352 " hh"
4353 ],
4354 "Should select next entry not on .gitignore"
4355 );
4356
4357 // Test 2: Auto selection with many gitignored files next to the deleted file
4358 submit_deletion(&panel, cx);
4359 assert_eq!(
4360 visible_entries_as_strings(&panel, 0..10, cx),
4361 &[
4362 "v root",
4363 " .gitignore",
4364 " gg <== selected",
4365 " hh"
4366 ],
4367 "Should select next entry not on .gitignore"
4368 );
4369
4370 // Test 3: Auto selection of entry before deleted file
4371 select_path(&panel, "root/hh", cx);
4372 assert_eq!(
4373 visible_entries_as_strings(&panel, 0..10, cx),
4374 &[
4375 "v root",
4376 " .gitignore",
4377 " gg",
4378 " hh <== selected"
4379 ],
4380 "Should select next entry not on .gitignore"
4381 );
4382 submit_deletion(&panel, cx);
4383 assert_eq!(
4384 visible_entries_as_strings(&panel, 0..10, cx),
4385 &["v root", " .gitignore", " gg <== selected"],
4386 "Should select next entry not on .gitignore"
4387 );
4388}
4389
4390#[gpui::test]
4391async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
4392 init_test_with_editor(cx);
4393
4394 let fs = FakeFs::new(cx.executor());
4395 fs.insert_tree(
4396 path!("/root"),
4397 json!({
4398 "dir1": {
4399 "file1": "// Testing",
4400 "file2": "// Testing",
4401 "file3": "// Testing"
4402 },
4403 "aa": "// Testing",
4404 ".gitignore": "file1\nfile3\n",
4405 }),
4406 )
4407 .await;
4408
4409 let project = Project::test(fs.clone(), [path!("/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
4413 cx.update(|_, cx| {
4414 let settings = *ProjectPanelSettings::get_global(cx);
4415 ProjectPanelSettings::override_global(
4416 ProjectPanelSettings {
4417 hide_gitignore: true,
4418 ..settings
4419 },
4420 cx,
4421 );
4422 });
4423
4424 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4425
4426 // Test 1: Visible items should exclude files on gitignore
4427 toggle_expand_dir(&panel, "root/dir1", cx);
4428 select_path(&panel, "root/dir1/file2", cx);
4429 assert_eq!(
4430 visible_entries_as_strings(&panel, 0..10, cx),
4431 &[
4432 "v root",
4433 " v dir1",
4434 " file2 <== selected",
4435 " .gitignore",
4436 " aa"
4437 ],
4438 "Initial state should hide files on .gitignore"
4439 );
4440 submit_deletion(&panel, cx);
4441
4442 // Test 2: Auto selection should go to the parent
4443 assert_eq!(
4444 visible_entries_as_strings(&panel, 0..10, cx),
4445 &[
4446 "v root",
4447 " v dir1 <== selected",
4448 " .gitignore",
4449 " aa"
4450 ],
4451 "Initial state should hide files on .gitignore"
4452 );
4453}
4454
4455#[gpui::test]
4456async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
4457 init_test_with_editor(cx);
4458
4459 let fs = FakeFs::new(cx.executor());
4460 fs.insert_tree(
4461 "/root",
4462 json!({
4463 "dir1": {
4464 "subdir1": {
4465 "a.txt": "",
4466 "b.txt": ""
4467 },
4468 "file1.txt": "",
4469 },
4470 "dir2": {
4471 "subdir2": {
4472 "c.txt": "",
4473 "d.txt": ""
4474 },
4475 "file2.txt": "",
4476 },
4477 "file3.txt": "",
4478 }),
4479 )
4480 .await;
4481
4482 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4483 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4484 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4485 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4486
4487 toggle_expand_dir(&panel, "root/dir1", cx);
4488 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4489 toggle_expand_dir(&panel, "root/dir2", cx);
4490 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4491
4492 // Test Case 1: Select and delete nested directory with parent
4493 cx.simulate_modifiers_change(gpui::Modifiers {
4494 control: true,
4495 ..Default::default()
4496 });
4497 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4498 select_path_with_mark(&panel, "root/dir1", cx);
4499
4500 assert_eq!(
4501 visible_entries_as_strings(&panel, 0..15, cx),
4502 &[
4503 "v root",
4504 " v dir1 <== selected <== marked",
4505 " v subdir1 <== marked",
4506 " a.txt",
4507 " b.txt",
4508 " file1.txt",
4509 " v dir2",
4510 " v subdir2",
4511 " c.txt",
4512 " d.txt",
4513 " file2.txt",
4514 " file3.txt",
4515 ],
4516 "Initial state before deleting nested directory with parent"
4517 );
4518
4519 submit_deletion(&panel, cx);
4520 assert_eq!(
4521 visible_entries_as_strings(&panel, 0..15, cx),
4522 &[
4523 "v root",
4524 " v dir2 <== selected",
4525 " v subdir2",
4526 " c.txt",
4527 " d.txt",
4528 " file2.txt",
4529 " file3.txt",
4530 ],
4531 "Should select next directory after deleting directory with parent"
4532 );
4533
4534 // Test Case 2: Select mixed files and directories across levels
4535 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
4536 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
4537 select_path_with_mark(&panel, "root/file3.txt", cx);
4538
4539 assert_eq!(
4540 visible_entries_as_strings(&panel, 0..15, cx),
4541 &[
4542 "v root",
4543 " v dir2",
4544 " v subdir2",
4545 " c.txt <== marked",
4546 " d.txt",
4547 " file2.txt <== marked",
4548 " file3.txt <== selected <== marked",
4549 ],
4550 "Initial state before deleting"
4551 );
4552
4553 submit_deletion(&panel, cx);
4554 assert_eq!(
4555 visible_entries_as_strings(&panel, 0..15, cx),
4556 &[
4557 "v root",
4558 " v dir2 <== selected",
4559 " v subdir2",
4560 " d.txt",
4561 ],
4562 "Should select sibling directory"
4563 );
4564}
4565
4566#[gpui::test]
4567async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
4568 init_test_with_editor(cx);
4569
4570 let fs = FakeFs::new(cx.executor());
4571 fs.insert_tree(
4572 "/root",
4573 json!({
4574 "dir1": {
4575 "subdir1": {
4576 "a.txt": "",
4577 "b.txt": ""
4578 },
4579 "file1.txt": "",
4580 },
4581 "dir2": {
4582 "subdir2": {
4583 "c.txt": "",
4584 "d.txt": ""
4585 },
4586 "file2.txt": "",
4587 },
4588 "file3.txt": "",
4589 "file4.txt": "",
4590 }),
4591 )
4592 .await;
4593
4594 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4595 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4596 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4597 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4598
4599 toggle_expand_dir(&panel, "root/dir1", cx);
4600 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4601 toggle_expand_dir(&panel, "root/dir2", cx);
4602 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
4603
4604 // Test Case 1: Select all root files and directories
4605 cx.simulate_modifiers_change(gpui::Modifiers {
4606 control: true,
4607 ..Default::default()
4608 });
4609 select_path_with_mark(&panel, "root/dir1", cx);
4610 select_path_with_mark(&panel, "root/dir2", cx);
4611 select_path_with_mark(&panel, "root/file3.txt", cx);
4612 select_path_with_mark(&panel, "root/file4.txt", cx);
4613 assert_eq!(
4614 visible_entries_as_strings(&panel, 0..20, cx),
4615 &[
4616 "v root",
4617 " v dir1 <== marked",
4618 " v subdir1",
4619 " a.txt",
4620 " b.txt",
4621 " file1.txt",
4622 " v dir2 <== marked",
4623 " v subdir2",
4624 " c.txt",
4625 " d.txt",
4626 " file2.txt",
4627 " file3.txt <== marked",
4628 " file4.txt <== selected <== marked",
4629 ],
4630 "State before deleting all contents"
4631 );
4632
4633 submit_deletion(&panel, cx);
4634 assert_eq!(
4635 visible_entries_as_strings(&panel, 0..20, cx),
4636 &["v root <== selected"],
4637 "Only empty root directory should remain after deleting all contents"
4638 );
4639}
4640
4641#[gpui::test]
4642async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
4643 init_test_with_editor(cx);
4644
4645 let fs = FakeFs::new(cx.executor());
4646 fs.insert_tree(
4647 "/root",
4648 json!({
4649 "dir1": {
4650 "subdir1": {
4651 "file_a.txt": "content a",
4652 "file_b.txt": "content b",
4653 },
4654 "subdir2": {
4655 "file_c.txt": "content c",
4656 },
4657 "file1.txt": "content 1",
4658 },
4659 "dir2": {
4660 "file2.txt": "content 2",
4661 },
4662 }),
4663 )
4664 .await;
4665
4666 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4667 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4668 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4669 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4670
4671 toggle_expand_dir(&panel, "root/dir1", cx);
4672 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
4673 toggle_expand_dir(&panel, "root/dir2", cx);
4674 cx.simulate_modifiers_change(gpui::Modifiers {
4675 control: true,
4676 ..Default::default()
4677 });
4678
4679 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
4680 select_path_with_mark(&panel, "root/dir1", cx);
4681 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
4682 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
4683
4684 assert_eq!(
4685 visible_entries_as_strings(&panel, 0..20, cx),
4686 &[
4687 "v root",
4688 " v dir1 <== marked",
4689 " v subdir1 <== marked",
4690 " file_a.txt <== selected <== marked",
4691 " file_b.txt",
4692 " > subdir2",
4693 " file1.txt",
4694 " v dir2",
4695 " file2.txt",
4696 ],
4697 "State with parent dir, subdir, and file selected"
4698 );
4699 submit_deletion(&panel, cx);
4700 assert_eq!(
4701 visible_entries_as_strings(&panel, 0..20, cx),
4702 &["v root", " v dir2 <== selected", " file2.txt",],
4703 "Only dir2 should remain after deletion"
4704 );
4705}
4706
4707#[gpui::test]
4708async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
4709 init_test_with_editor(cx);
4710
4711 let fs = FakeFs::new(cx.executor());
4712 // First worktree
4713 fs.insert_tree(
4714 "/root1",
4715 json!({
4716 "dir1": {
4717 "file1.txt": "content 1",
4718 "file2.txt": "content 2",
4719 },
4720 "dir2": {
4721 "file3.txt": "content 3",
4722 },
4723 }),
4724 )
4725 .await;
4726
4727 // Second worktree
4728 fs.insert_tree(
4729 "/root2",
4730 json!({
4731 "dir3": {
4732 "file4.txt": "content 4",
4733 "file5.txt": "content 5",
4734 },
4735 "file6.txt": "content 6",
4736 }),
4737 )
4738 .await;
4739
4740 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4741 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4742 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4743 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4744
4745 // Expand all directories for testing
4746 toggle_expand_dir(&panel, "root1/dir1", cx);
4747 toggle_expand_dir(&panel, "root1/dir2", cx);
4748 toggle_expand_dir(&panel, "root2/dir3", cx);
4749
4750 // Test Case 1: Delete files across different worktrees
4751 cx.simulate_modifiers_change(gpui::Modifiers {
4752 control: true,
4753 ..Default::default()
4754 });
4755 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
4756 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
4757
4758 assert_eq!(
4759 visible_entries_as_strings(&panel, 0..20, cx),
4760 &[
4761 "v root1",
4762 " v dir1",
4763 " file1.txt <== marked",
4764 " file2.txt",
4765 " v dir2",
4766 " file3.txt",
4767 "v root2",
4768 " v dir3",
4769 " file4.txt <== selected <== marked",
4770 " file5.txt",
4771 " file6.txt",
4772 ],
4773 "Initial state with files selected from different worktrees"
4774 );
4775
4776 submit_deletion(&panel, cx);
4777 assert_eq!(
4778 visible_entries_as_strings(&panel, 0..20, cx),
4779 &[
4780 "v root1",
4781 " v dir1",
4782 " file2.txt",
4783 " v dir2",
4784 " file3.txt",
4785 "v root2",
4786 " v dir3",
4787 " file5.txt <== selected",
4788 " file6.txt",
4789 ],
4790 "Should select next file in the last worktree after deletion"
4791 );
4792
4793 // Test Case 2: Delete directories from different worktrees
4794 select_path_with_mark(&panel, "root1/dir1", cx);
4795 select_path_with_mark(&panel, "root2/dir3", cx);
4796
4797 assert_eq!(
4798 visible_entries_as_strings(&panel, 0..20, cx),
4799 &[
4800 "v root1",
4801 " v dir1 <== marked",
4802 " file2.txt",
4803 " v dir2",
4804 " file3.txt",
4805 "v root2",
4806 " v dir3 <== selected <== marked",
4807 " file5.txt",
4808 " file6.txt",
4809 ],
4810 "State with directories marked from different worktrees"
4811 );
4812
4813 submit_deletion(&panel, cx);
4814 assert_eq!(
4815 visible_entries_as_strings(&panel, 0..20, cx),
4816 &[
4817 "v root1",
4818 " v dir2",
4819 " file3.txt",
4820 "v root2",
4821 " file6.txt <== selected",
4822 ],
4823 "Should select remaining file in last worktree after directory deletion"
4824 );
4825
4826 // Test Case 4: Delete all remaining files except roots
4827 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
4828 select_path_with_mark(&panel, "root2/file6.txt", cx);
4829
4830 assert_eq!(
4831 visible_entries_as_strings(&panel, 0..20, cx),
4832 &[
4833 "v root1",
4834 " v dir2",
4835 " file3.txt <== marked",
4836 "v root2",
4837 " file6.txt <== selected <== marked",
4838 ],
4839 "State with all remaining files marked"
4840 );
4841
4842 submit_deletion(&panel, cx);
4843 assert_eq!(
4844 visible_entries_as_strings(&panel, 0..20, cx),
4845 &["v root1", " v dir2", "v root2 <== selected"],
4846 "Second parent root should be selected after deleting"
4847 );
4848}
4849
4850#[gpui::test]
4851async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
4852 init_test_with_editor(cx);
4853
4854 let fs = FakeFs::new(cx.executor());
4855 fs.insert_tree(
4856 "/root",
4857 json!({
4858 "dir1": {
4859 "file1.txt": "",
4860 "file2.txt": "",
4861 "file3.txt": "",
4862 },
4863 "dir2": {
4864 "file4.txt": "",
4865 "file5.txt": "",
4866 },
4867 }),
4868 )
4869 .await;
4870
4871 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4872 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4873 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4874 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4875
4876 toggle_expand_dir(&panel, "root/dir1", cx);
4877 toggle_expand_dir(&panel, "root/dir2", cx);
4878
4879 cx.simulate_modifiers_change(gpui::Modifiers {
4880 control: true,
4881 ..Default::default()
4882 });
4883
4884 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
4885 select_path(&panel, "root/dir1/file1.txt", cx);
4886
4887 assert_eq!(
4888 visible_entries_as_strings(&panel, 0..15, cx),
4889 &[
4890 "v root",
4891 " v dir1",
4892 " file1.txt <== selected",
4893 " file2.txt <== marked",
4894 " file3.txt",
4895 " v dir2",
4896 " file4.txt",
4897 " file5.txt",
4898 ],
4899 "Initial state with one marked entry and different selection"
4900 );
4901
4902 // Delete should operate on the selected entry (file1.txt)
4903 submit_deletion(&panel, cx);
4904 assert_eq!(
4905 visible_entries_as_strings(&panel, 0..15, cx),
4906 &[
4907 "v root",
4908 " v dir1",
4909 " file2.txt <== selected <== marked",
4910 " file3.txt",
4911 " v dir2",
4912 " file4.txt",
4913 " file5.txt",
4914 ],
4915 "Should delete selected file, not marked file"
4916 );
4917
4918 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
4919 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
4920 select_path(&panel, "root/dir2/file5.txt", cx);
4921
4922 assert_eq!(
4923 visible_entries_as_strings(&panel, 0..15, cx),
4924 &[
4925 "v root",
4926 " v dir1",
4927 " file2.txt <== marked",
4928 " file3.txt <== marked",
4929 " v dir2",
4930 " file4.txt <== marked",
4931 " file5.txt <== selected",
4932 ],
4933 "Initial state with multiple marked entries and different selection"
4934 );
4935
4936 // Delete should operate on all marked entries, ignoring the selection
4937 submit_deletion(&panel, cx);
4938 assert_eq!(
4939 visible_entries_as_strings(&panel, 0..15, cx),
4940 &[
4941 "v root",
4942 " v dir1",
4943 " v dir2",
4944 " file5.txt <== selected",
4945 ],
4946 "Should delete all marked files, leaving only the selected file"
4947 );
4948}
4949
4950#[gpui::test]
4951async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
4952 init_test_with_editor(cx);
4953
4954 let fs = FakeFs::new(cx.executor());
4955 fs.insert_tree(
4956 "/root_b",
4957 json!({
4958 "dir1": {
4959 "file1.txt": "content 1",
4960 "file2.txt": "content 2",
4961 },
4962 }),
4963 )
4964 .await;
4965
4966 fs.insert_tree(
4967 "/root_c",
4968 json!({
4969 "dir2": {},
4970 }),
4971 )
4972 .await;
4973
4974 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
4975 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4976 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4977 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4978
4979 toggle_expand_dir(&panel, "root_b/dir1", cx);
4980 toggle_expand_dir(&panel, "root_c/dir2", cx);
4981
4982 cx.simulate_modifiers_change(gpui::Modifiers {
4983 control: true,
4984 ..Default::default()
4985 });
4986 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
4987 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
4988
4989 assert_eq!(
4990 visible_entries_as_strings(&panel, 0..20, cx),
4991 &[
4992 "v root_b",
4993 " v dir1",
4994 " file1.txt <== marked",
4995 " file2.txt <== selected <== marked",
4996 "v root_c",
4997 " v dir2",
4998 ],
4999 "Initial state with files marked in root_b"
5000 );
5001
5002 submit_deletion(&panel, cx);
5003 assert_eq!(
5004 visible_entries_as_strings(&panel, 0..20, cx),
5005 &[
5006 "v root_b",
5007 " v dir1 <== selected",
5008 "v root_c",
5009 " v dir2",
5010 ],
5011 "After deletion in root_b as it's last deletion, selection should be in root_b"
5012 );
5013
5014 select_path_with_mark(&panel, "root_c/dir2", cx);
5015
5016 submit_deletion(&panel, cx);
5017 assert_eq!(
5018 visible_entries_as_strings(&panel, 0..20, cx),
5019 &["v root_b", " v dir1", "v root_c <== selected",],
5020 "After deleting from root_c, it should remain in root_c"
5021 );
5022}
5023
5024fn toggle_expand_dir(
5025 panel: &Entity<ProjectPanel>,
5026 path: impl AsRef<Path>,
5027 cx: &mut VisualTestContext,
5028) {
5029 let path = path.as_ref();
5030 panel.update_in(cx, |panel, window, cx| {
5031 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5032 let worktree = worktree.read(cx);
5033 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5034 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5035 panel.toggle_expanded(entry_id, window, cx);
5036 return;
5037 }
5038 }
5039 panic!("no worktree for path {:?}", path);
5040 });
5041}
5042
5043#[gpui::test]
5044async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
5045 init_test_with_editor(cx);
5046
5047 let fs = FakeFs::new(cx.executor());
5048 fs.insert_tree(
5049 path!("/root"),
5050 json!({
5051 ".gitignore": "**/ignored_dir\n**/ignored_nested",
5052 "dir1": {
5053 "empty1": {
5054 "empty2": {
5055 "empty3": {
5056 "file.txt": ""
5057 }
5058 }
5059 },
5060 "subdir1": {
5061 "file1.txt": "",
5062 "file2.txt": "",
5063 "ignored_nested": {
5064 "ignored_file.txt": ""
5065 }
5066 },
5067 "ignored_dir": {
5068 "subdir": {
5069 "deep_file.txt": ""
5070 }
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: When auto-fold is enabled
5082 cx.update(|_, cx| {
5083 let settings = *ProjectPanelSettings::get_global(cx);
5084 ProjectPanelSettings::override_global(
5085 ProjectPanelSettings {
5086 auto_fold_dirs: true,
5087 ..settings
5088 },
5089 cx,
5090 );
5091 });
5092
5093 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5094
5095 assert_eq!(
5096 visible_entries_as_strings(&panel, 0..20, cx),
5097 &["v root", " > dir1", " .gitignore",],
5098 "Initial state should show collapsed root structure"
5099 );
5100
5101 toggle_expand_dir(&panel, "root/dir1", cx);
5102 assert_eq!(
5103 visible_entries_as_strings(&panel, 0..20, cx),
5104 &[
5105 "v root",
5106 " v dir1 <== selected",
5107 " > empty1/empty2/empty3",
5108 " > ignored_dir",
5109 " > subdir1",
5110 " .gitignore",
5111 ],
5112 "Should show first level with auto-folded dirs and ignored dir visible"
5113 );
5114
5115 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5116 panel.update(cx, |panel, cx| {
5117 let project = panel.project.read(cx);
5118 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5119 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5120 panel.update_visible_entries(None, cx);
5121 });
5122 cx.run_until_parked();
5123
5124 assert_eq!(
5125 visible_entries_as_strings(&panel, 0..20, cx),
5126 &[
5127 "v root",
5128 " v dir1 <== selected",
5129 " v empty1",
5130 " v empty2",
5131 " v empty3",
5132 " file.txt",
5133 " > ignored_dir",
5134 " v subdir1",
5135 " > ignored_nested",
5136 " file1.txt",
5137 " file2.txt",
5138 " .gitignore",
5139 ],
5140 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
5141 );
5142
5143 // Test 2: When auto-fold is disabled
5144 cx.update(|_, cx| {
5145 let settings = *ProjectPanelSettings::get_global(cx);
5146 ProjectPanelSettings::override_global(
5147 ProjectPanelSettings {
5148 auto_fold_dirs: false,
5149 ..settings
5150 },
5151 cx,
5152 );
5153 });
5154
5155 panel.update_in(cx, |panel, window, cx| {
5156 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
5157 });
5158
5159 toggle_expand_dir(&panel, "root/dir1", cx);
5160 assert_eq!(
5161 visible_entries_as_strings(&panel, 0..20, cx),
5162 &[
5163 "v root",
5164 " v dir1 <== selected",
5165 " > empty1",
5166 " > ignored_dir",
5167 " > subdir1",
5168 " .gitignore",
5169 ],
5170 "With auto-fold disabled: should show all directories separately"
5171 );
5172
5173 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5174 panel.update(cx, |panel, cx| {
5175 let project = panel.project.read(cx);
5176 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5177 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
5178 panel.update_visible_entries(None, cx);
5179 });
5180 cx.run_until_parked();
5181
5182 assert_eq!(
5183 visible_entries_as_strings(&panel, 0..20, cx),
5184 &[
5185 "v root",
5186 " v dir1 <== selected",
5187 " v empty1",
5188 " v empty2",
5189 " v empty3",
5190 " file.txt",
5191 " > ignored_dir",
5192 " v subdir1",
5193 " > ignored_nested",
5194 " file1.txt",
5195 " file2.txt",
5196 " .gitignore",
5197 ],
5198 "After expand_all without auto-fold: should expand all dirs normally, \
5199 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
5200 );
5201
5202 // Test 3: When explicitly called on ignored directory
5203 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
5204 panel.update(cx, |panel, cx| {
5205 let project = panel.project.read(cx);
5206 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5207 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
5208 panel.update_visible_entries(None, cx);
5209 });
5210 cx.run_until_parked();
5211
5212 assert_eq!(
5213 visible_entries_as_strings(&panel, 0..20, cx),
5214 &[
5215 "v root",
5216 " v dir1 <== selected",
5217 " v empty1",
5218 " v empty2",
5219 " v empty3",
5220 " file.txt",
5221 " v ignored_dir",
5222 " v subdir",
5223 " deep_file.txt",
5224 " v subdir1",
5225 " > ignored_nested",
5226 " file1.txt",
5227 " file2.txt",
5228 " .gitignore",
5229 ],
5230 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
5231 );
5232}
5233
5234#[gpui::test]
5235async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
5236 init_test(cx);
5237
5238 let fs = FakeFs::new(cx.executor());
5239 fs.insert_tree(
5240 path!("/root"),
5241 json!({
5242 "dir1": {
5243 "subdir1": {
5244 "nested1": {
5245 "file1.txt": "",
5246 "file2.txt": ""
5247 },
5248 },
5249 "subdir2": {
5250 "file4.txt": ""
5251 }
5252 },
5253 "dir2": {
5254 "single_file": {
5255 "file5.txt": ""
5256 }
5257 }
5258 }),
5259 )
5260 .await;
5261
5262 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5263 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5264 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5265
5266 // Test 1: Basic collapsing
5267 {
5268 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5269
5270 toggle_expand_dir(&panel, "root/dir1", cx);
5271 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5272 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5273 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
5274
5275 assert_eq!(
5276 visible_entries_as_strings(&panel, 0..20, cx),
5277 &[
5278 "v root",
5279 " v dir1",
5280 " v subdir1",
5281 " v nested1",
5282 " file1.txt",
5283 " file2.txt",
5284 " v subdir2 <== selected",
5285 " file4.txt",
5286 " > dir2",
5287 ],
5288 "Initial state with everything expanded"
5289 );
5290
5291 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5292 panel.update(cx, |panel, cx| {
5293 let project = panel.project.read(cx);
5294 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5295 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5296 panel.update_visible_entries(None, cx);
5297 });
5298
5299 assert_eq!(
5300 visible_entries_as_strings(&panel, 0..20, cx),
5301 &["v root", " > dir1", " > dir2",],
5302 "All subdirs under dir1 should be collapsed"
5303 );
5304 }
5305
5306 // Test 2: With auto-fold enabled
5307 {
5308 cx.update(|_, cx| {
5309 let settings = *ProjectPanelSettings::get_global(cx);
5310 ProjectPanelSettings::override_global(
5311 ProjectPanelSettings {
5312 auto_fold_dirs: true,
5313 ..settings
5314 },
5315 cx,
5316 );
5317 });
5318
5319 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5320
5321 toggle_expand_dir(&panel, "root/dir1", cx);
5322 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5323 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5324
5325 assert_eq!(
5326 visible_entries_as_strings(&panel, 0..20, cx),
5327 &[
5328 "v root",
5329 " v dir1",
5330 " v subdir1/nested1 <== selected",
5331 " file1.txt",
5332 " file2.txt",
5333 " > subdir2",
5334 " > dir2/single_file",
5335 ],
5336 "Initial state with some dirs expanded"
5337 );
5338
5339 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5340 panel.update(cx, |panel, cx| {
5341 let project = panel.project.read(cx);
5342 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5343 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5344 });
5345
5346 toggle_expand_dir(&panel, "root/dir1", cx);
5347
5348 assert_eq!(
5349 visible_entries_as_strings(&panel, 0..20, cx),
5350 &[
5351 "v root",
5352 " v dir1 <== selected",
5353 " > subdir1/nested1",
5354 " > subdir2",
5355 " > dir2/single_file",
5356 ],
5357 "Subdirs should be collapsed and folded with auto-fold enabled"
5358 );
5359 }
5360
5361 // Test 3: With auto-fold disabled
5362 {
5363 cx.update(|_, cx| {
5364 let settings = *ProjectPanelSettings::get_global(cx);
5365 ProjectPanelSettings::override_global(
5366 ProjectPanelSettings {
5367 auto_fold_dirs: false,
5368 ..settings
5369 },
5370 cx,
5371 );
5372 });
5373
5374 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5375
5376 toggle_expand_dir(&panel, "root/dir1", cx);
5377 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5378 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
5379
5380 assert_eq!(
5381 visible_entries_as_strings(&panel, 0..20, cx),
5382 &[
5383 "v root",
5384 " v dir1",
5385 " v subdir1",
5386 " v nested1 <== selected",
5387 " file1.txt",
5388 " file2.txt",
5389 " > subdir2",
5390 " > dir2",
5391 ],
5392 "Initial state with some dirs expanded and auto-fold disabled"
5393 );
5394
5395 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
5396 panel.update(cx, |panel, cx| {
5397 let project = panel.project.read(cx);
5398 let worktree = project.worktrees(cx).next().unwrap().read(cx);
5399 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
5400 });
5401
5402 toggle_expand_dir(&panel, "root/dir1", cx);
5403
5404 assert_eq!(
5405 visible_entries_as_strings(&panel, 0..20, cx),
5406 &[
5407 "v root",
5408 " v dir1 <== selected",
5409 " > subdir1",
5410 " > subdir2",
5411 " > dir2",
5412 ],
5413 "Subdirs should be collapsed but not folded with auto-fold disabled"
5414 );
5415 }
5416}
5417
5418#[gpui::test]
5419async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
5420 init_test(cx);
5421
5422 let fs = FakeFs::new(cx.executor());
5423 fs.insert_tree(
5424 path!("/root"),
5425 json!({
5426 "dir1": {
5427 "file1.txt": "",
5428 },
5429 }),
5430 )
5431 .await;
5432
5433 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5434 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5435 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5436
5437 let panel = workspace
5438 .update(cx, |workspace, window, cx| {
5439 let panel = ProjectPanel::new(workspace, window, cx);
5440 workspace.add_panel(panel.clone(), window, cx);
5441 panel
5442 })
5443 .unwrap();
5444
5445 #[rustfmt::skip]
5446 assert_eq!(
5447 visible_entries_as_strings(&panel, 0..20, cx),
5448 &[
5449 "v root",
5450 " > dir1",
5451 ],
5452 "Initial state with nothing selected"
5453 );
5454
5455 panel.update_in(cx, |panel, window, cx| {
5456 panel.new_file(&NewFile, window, cx);
5457 });
5458 panel.update_in(cx, |panel, window, cx| {
5459 assert!(panel.filename_editor.read(cx).is_focused(window));
5460 });
5461 panel
5462 .update_in(cx, |panel, window, cx| {
5463 panel.filename_editor.update(cx, |editor, cx| {
5464 editor.set_text("hello_from_no_selections", window, cx)
5465 });
5466 panel.confirm_edit(window, cx).unwrap()
5467 })
5468 .await
5469 .unwrap();
5470
5471 #[rustfmt::skip]
5472 assert_eq!(
5473 visible_entries_as_strings(&panel, 0..20, cx),
5474 &[
5475 "v root",
5476 " > dir1",
5477 " hello_from_no_selections <== selected <== marked",
5478 ],
5479 "A new file is created under the root directory"
5480 );
5481}
5482
5483#[gpui::test]
5484async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
5485 init_test(cx);
5486
5487 let fs = FakeFs::new(cx.executor());
5488 fs.insert_tree(
5489 path!("/root"),
5490 json!({
5491 "existing_dir": {
5492 "existing_file.txt": "",
5493 },
5494 "existing_file.txt": "",
5495 }),
5496 )
5497 .await;
5498
5499 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5500 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5501 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5502
5503 cx.update(|_, cx| {
5504 let settings = *ProjectPanelSettings::get_global(cx);
5505 ProjectPanelSettings::override_global(
5506 ProjectPanelSettings {
5507 hide_root: true,
5508 ..settings
5509 },
5510 cx,
5511 );
5512 });
5513
5514 let panel = workspace
5515 .update(cx, |workspace, window, cx| {
5516 let panel = ProjectPanel::new(workspace, window, cx);
5517 workspace.add_panel(panel.clone(), window, cx);
5518 panel
5519 })
5520 .unwrap();
5521
5522 #[rustfmt::skip]
5523 assert_eq!(
5524 visible_entries_as_strings(&panel, 0..20, cx),
5525 &[
5526 "> existing_dir",
5527 " existing_file.txt",
5528 ],
5529 "Initial state with hide_root=true, root should be hidden and nothing selected"
5530 );
5531
5532 panel.update(cx, |panel, _| {
5533 assert!(
5534 panel.selection.is_none(),
5535 "Should have no selection initially"
5536 );
5537 });
5538
5539 // Test 1: Create new file when no entry is selected
5540 panel.update_in(cx, |panel, window, cx| {
5541 panel.new_file(&NewFile, window, cx);
5542 });
5543 panel.update_in(cx, |panel, window, cx| {
5544 assert!(panel.filename_editor.read(cx).is_focused(window));
5545 });
5546
5547 #[rustfmt::skip]
5548 assert_eq!(
5549 visible_entries_as_strings(&panel, 0..20, cx),
5550 &[
5551 "> existing_dir",
5552 " [EDITOR: ''] <== selected",
5553 " existing_file.txt",
5554 ],
5555 "Editor should appear at root level when hide_root=true and no selection"
5556 );
5557
5558 let confirm = panel.update_in(cx, |panel, window, cx| {
5559 panel.filename_editor.update(cx, |editor, cx| {
5560 editor.set_text("new_file_at_root.txt", window, cx)
5561 });
5562 panel.confirm_edit(window, cx).unwrap()
5563 });
5564 confirm.await.unwrap();
5565
5566 #[rustfmt::skip]
5567 assert_eq!(
5568 visible_entries_as_strings(&panel, 0..20, cx),
5569 &[
5570 "> existing_dir",
5571 " existing_file.txt",
5572 " new_file_at_root.txt <== selected <== marked",
5573 ],
5574 "New file should be created at root level and visible without root prefix"
5575 );
5576
5577 assert!(
5578 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
5579 "File should be created in the actual root directory"
5580 );
5581
5582 // Test 2: Create new directory when no entry is selected
5583 panel.update(cx, |panel, _| {
5584 panel.selection = None;
5585 });
5586
5587 panel.update_in(cx, |panel, window, cx| {
5588 panel.new_directory(&NewDirectory, window, cx);
5589 });
5590 panel.update_in(cx, |panel, window, cx| {
5591 assert!(panel.filename_editor.read(cx).is_focused(window));
5592 });
5593
5594 #[rustfmt::skip]
5595 assert_eq!(
5596 visible_entries_as_strings(&panel, 0..20, cx),
5597 &[
5598 "> [EDITOR: ''] <== selected",
5599 "> existing_dir",
5600 " existing_file.txt",
5601 " new_file_at_root.txt",
5602 ],
5603 "Directory editor should appear at root level when hide_root=true and no selection"
5604 );
5605
5606 let confirm = panel.update_in(cx, |panel, window, cx| {
5607 panel.filename_editor.update(cx, |editor, cx| {
5608 editor.set_text("new_dir_at_root", window, cx)
5609 });
5610 panel.confirm_edit(window, cx).unwrap()
5611 });
5612 confirm.await.unwrap();
5613
5614 #[rustfmt::skip]
5615 assert_eq!(
5616 visible_entries_as_strings(&panel, 0..20, cx),
5617 &[
5618 "> existing_dir",
5619 "v new_dir_at_root <== selected",
5620 " existing_file.txt",
5621 " new_file_at_root.txt",
5622 ],
5623 "New directory should be created at root level and visible without root prefix"
5624 );
5625
5626 assert!(
5627 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
5628 "Directory should be created in the actual root directory"
5629 );
5630}
5631
5632#[gpui::test]
5633async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
5634 init_test(cx);
5635
5636 let fs = FakeFs::new(cx.executor());
5637 fs.insert_tree(
5638 "/root",
5639 json!({
5640 "dir1": {
5641 "file1.txt": "",
5642 "dir2": {
5643 "file2.txt": ""
5644 }
5645 },
5646 "file3.txt": ""
5647 }),
5648 )
5649 .await;
5650
5651 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5652 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5653 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5654 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5655
5656 panel.update(cx, |panel, cx| {
5657 let project = panel.project.read(cx);
5658 let worktree = project.visible_worktrees(cx).next().unwrap();
5659 let worktree = worktree.read(cx);
5660
5661 // Test 1: Target is a directory, should highlight the directory itself
5662 let dir_entry = worktree.entry_for_path("dir1").unwrap();
5663 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
5664 assert_eq!(
5665 result,
5666 Some(dir_entry.id),
5667 "Should highlight directory itself"
5668 );
5669
5670 // Test 2: Target is nested file, should highlight immediate parent
5671 let nested_file = worktree.entry_for_path("dir1/dir2/file2.txt").unwrap();
5672 let nested_parent = worktree.entry_for_path("dir1/dir2").unwrap();
5673 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
5674 assert_eq!(
5675 result,
5676 Some(nested_parent.id),
5677 "Should highlight immediate parent"
5678 );
5679
5680 // Test 3: Target is root level file, should highlight root
5681 let root_file = worktree.entry_for_path("file3.txt").unwrap();
5682 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
5683 assert_eq!(
5684 result,
5685 Some(worktree.root_entry().unwrap().id),
5686 "Root level file should return None"
5687 );
5688
5689 // Test 4: Target is root itself, should highlight root
5690 let root_entry = worktree.root_entry().unwrap();
5691 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
5692 assert_eq!(
5693 result,
5694 Some(root_entry.id),
5695 "Root level file should return None"
5696 );
5697 });
5698}
5699
5700#[gpui::test]
5701async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
5702 init_test(cx);
5703
5704 let fs = FakeFs::new(cx.executor());
5705 fs.insert_tree(
5706 "/root",
5707 json!({
5708 "parent_dir": {
5709 "child_file.txt": "",
5710 "sibling_file.txt": "",
5711 "child_dir": {
5712 "nested_file.txt": ""
5713 }
5714 },
5715 "other_dir": {
5716 "other_file.txt": ""
5717 }
5718 }),
5719 )
5720 .await;
5721
5722 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5723 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5724 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5725 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5726
5727 panel.update(cx, |panel, cx| {
5728 let project = panel.project.read(cx);
5729 let worktree = project.visible_worktrees(cx).next().unwrap();
5730 let worktree_id = worktree.read(cx).id();
5731 let worktree = worktree.read(cx);
5732
5733 let parent_dir = worktree.entry_for_path("parent_dir").unwrap();
5734 let child_file = worktree
5735 .entry_for_path("parent_dir/child_file.txt")
5736 .unwrap();
5737 let sibling_file = worktree
5738 .entry_for_path("parent_dir/sibling_file.txt")
5739 .unwrap();
5740 let child_dir = worktree.entry_for_path("parent_dir/child_dir").unwrap();
5741 let other_dir = worktree.entry_for_path("other_dir").unwrap();
5742 let other_file = worktree.entry_for_path("other_dir/other_file.txt").unwrap();
5743
5744 // Test 1: Single item drag, don't highlight parent directory
5745 let dragged_selection = DraggedSelection {
5746 active_selection: SelectedEntry {
5747 worktree_id,
5748 entry_id: child_file.id,
5749 },
5750 marked_selections: Arc::new([SelectedEntry {
5751 worktree_id,
5752 entry_id: child_file.id,
5753 }]),
5754 };
5755 let result =
5756 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5757 assert_eq!(result, None, "Should not highlight parent of dragged item");
5758
5759 // Test 2: Single item drag, don't highlight sibling files
5760 let result = panel.highlight_entry_for_selection_drag(
5761 sibling_file,
5762 worktree,
5763 &dragged_selection,
5764 cx,
5765 );
5766 assert_eq!(result, None, "Should not highlight sibling files");
5767
5768 // Test 3: Single item drag, highlight unrelated directory
5769 let result =
5770 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
5771 assert_eq!(
5772 result,
5773 Some(other_dir.id),
5774 "Should highlight unrelated directory"
5775 );
5776
5777 // Test 4: Single item drag, highlight sibling directory
5778 let result =
5779 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5780 assert_eq!(
5781 result,
5782 Some(child_dir.id),
5783 "Should highlight sibling directory"
5784 );
5785
5786 // Test 5: Multiple items drag, highlight parent directory
5787 let dragged_selection = DraggedSelection {
5788 active_selection: SelectedEntry {
5789 worktree_id,
5790 entry_id: child_file.id,
5791 },
5792 marked_selections: Arc::new([
5793 SelectedEntry {
5794 worktree_id,
5795 entry_id: child_file.id,
5796 },
5797 SelectedEntry {
5798 worktree_id,
5799 entry_id: sibling_file.id,
5800 },
5801 ]),
5802 };
5803 let result =
5804 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
5805 assert_eq!(
5806 result,
5807 Some(parent_dir.id),
5808 "Should highlight parent with multiple items"
5809 );
5810
5811 // Test 6: Target is file in different directory, highlight parent
5812 let result =
5813 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
5814 assert_eq!(
5815 result,
5816 Some(other_dir.id),
5817 "Should highlight parent of target file"
5818 );
5819
5820 // Test 7: Target is directory, always highlight
5821 let result =
5822 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
5823 assert_eq!(
5824 result,
5825 Some(child_dir.id),
5826 "Should always highlight directories"
5827 );
5828 });
5829}
5830
5831#[gpui::test]
5832async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
5833 init_test(cx);
5834
5835 let fs = FakeFs::new(cx.executor());
5836 fs.insert_tree(
5837 "/root1",
5838 json!({
5839 "src": {
5840 "main.rs": "",
5841 "lib.rs": ""
5842 }
5843 }),
5844 )
5845 .await;
5846 fs.insert_tree(
5847 "/root2",
5848 json!({
5849 "src": {
5850 "main.rs": "",
5851 "test.rs": ""
5852 }
5853 }),
5854 )
5855 .await;
5856
5857 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5858 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5859 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5860 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5861
5862 panel.update(cx, |panel, cx| {
5863 let project = panel.project.read(cx);
5864 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
5865
5866 let worktree_a = &worktrees[0];
5867 let main_rs_from_a = worktree_a.read(cx).entry_for_path("src/main.rs").unwrap();
5868
5869 let worktree_b = &worktrees[1];
5870 let src_dir_from_b = worktree_b.read(cx).entry_for_path("src").unwrap();
5871 let main_rs_from_b = worktree_b.read(cx).entry_for_path("src/main.rs").unwrap();
5872
5873 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
5874 let dragged_selection = DraggedSelection {
5875 active_selection: SelectedEntry {
5876 worktree_id: worktree_a.read(cx).id(),
5877 entry_id: main_rs_from_a.id,
5878 },
5879 marked_selections: Arc::new([SelectedEntry {
5880 worktree_id: worktree_a.read(cx).id(),
5881 entry_id: main_rs_from_a.id,
5882 }]),
5883 };
5884
5885 let result = panel.highlight_entry_for_selection_drag(
5886 src_dir_from_b,
5887 worktree_b.read(cx),
5888 &dragged_selection,
5889 cx,
5890 );
5891 assert_eq!(
5892 result,
5893 Some(src_dir_from_b.id),
5894 "Should highlight target directory from different worktree even with same relative path"
5895 );
5896
5897 // Test dragging file from worktree A onto file with same relative path in worktree B
5898 let result = panel.highlight_entry_for_selection_drag(
5899 main_rs_from_b,
5900 worktree_b.read(cx),
5901 &dragged_selection,
5902 cx,
5903 );
5904 assert_eq!(
5905 result,
5906 Some(src_dir_from_b.id),
5907 "Should highlight parent of target file from different worktree"
5908 );
5909 });
5910}
5911
5912#[gpui::test]
5913async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
5914 init_test(cx);
5915
5916 let fs = FakeFs::new(cx.executor());
5917 fs.insert_tree(
5918 "/root1",
5919 json!({
5920 "parent_dir": {
5921 "child_file.txt": "",
5922 "nested_dir": {
5923 "nested_file.txt": ""
5924 }
5925 },
5926 "root_file.txt": ""
5927 }),
5928 )
5929 .await;
5930
5931 fs.insert_tree(
5932 "/root2",
5933 json!({
5934 "other_dir": {
5935 "other_file.txt": ""
5936 }
5937 }),
5938 )
5939 .await;
5940
5941 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5942 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5943 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5944 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5945
5946 panel.update(cx, |panel, cx| {
5947 let project = panel.project.read(cx);
5948 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
5949 let worktree1 = worktrees[0].read(cx);
5950 let worktree2 = worktrees[1].read(cx);
5951 let worktree1_id = worktree1.id();
5952 let _worktree2_id = worktree2.id();
5953
5954 let root1_entry = worktree1.root_entry().unwrap();
5955 let root2_entry = worktree2.root_entry().unwrap();
5956 let _parent_dir = worktree1.entry_for_path("parent_dir").unwrap();
5957 let child_file = worktree1
5958 .entry_for_path("parent_dir/child_file.txt")
5959 .unwrap();
5960 let nested_file = worktree1
5961 .entry_for_path("parent_dir/nested_dir/nested_file.txt")
5962 .unwrap();
5963 let root_file = worktree1.entry_for_path("root_file.txt").unwrap();
5964
5965 // Test 1: Multiple entries - should always highlight background
5966 let multiple_dragged_selection = DraggedSelection {
5967 active_selection: SelectedEntry {
5968 worktree_id: worktree1_id,
5969 entry_id: child_file.id,
5970 },
5971 marked_selections: Arc::new([
5972 SelectedEntry {
5973 worktree_id: worktree1_id,
5974 entry_id: child_file.id,
5975 },
5976 SelectedEntry {
5977 worktree_id: worktree1_id,
5978 entry_id: nested_file.id,
5979 },
5980 ]),
5981 };
5982
5983 let result = panel.should_highlight_background_for_selection_drag(
5984 &multiple_dragged_selection,
5985 root1_entry.id,
5986 cx,
5987 );
5988 assert!(result, "Should highlight background for multiple entries");
5989
5990 // Test 2: Single entry with non-empty parent path - should highlight background
5991 let nested_dragged_selection = DraggedSelection {
5992 active_selection: SelectedEntry {
5993 worktree_id: worktree1_id,
5994 entry_id: nested_file.id,
5995 },
5996 marked_selections: Arc::new([SelectedEntry {
5997 worktree_id: worktree1_id,
5998 entry_id: nested_file.id,
5999 }]),
6000 };
6001
6002 let result = panel.should_highlight_background_for_selection_drag(
6003 &nested_dragged_selection,
6004 root1_entry.id,
6005 cx,
6006 );
6007 assert!(result, "Should highlight background for nested file");
6008
6009 // Test 3: Single entry at root level, same worktree - should NOT highlight background
6010 let root_file_dragged_selection = DraggedSelection {
6011 active_selection: SelectedEntry {
6012 worktree_id: worktree1_id,
6013 entry_id: root_file.id,
6014 },
6015 marked_selections: Arc::new([SelectedEntry {
6016 worktree_id: worktree1_id,
6017 entry_id: root_file.id,
6018 }]),
6019 };
6020
6021 let result = panel.should_highlight_background_for_selection_drag(
6022 &root_file_dragged_selection,
6023 root1_entry.id,
6024 cx,
6025 );
6026 assert!(
6027 !result,
6028 "Should NOT highlight background for root file in same worktree"
6029 );
6030
6031 // Test 4: Single entry at root level, different worktree - should highlight background
6032 let result = panel.should_highlight_background_for_selection_drag(
6033 &root_file_dragged_selection,
6034 root2_entry.id,
6035 cx,
6036 );
6037 assert!(
6038 result,
6039 "Should highlight background for root file from different worktree"
6040 );
6041
6042 // Test 5: Single entry in subdirectory - should highlight background
6043 let child_file_dragged_selection = DraggedSelection {
6044 active_selection: SelectedEntry {
6045 worktree_id: worktree1_id,
6046 entry_id: child_file.id,
6047 },
6048 marked_selections: Arc::new([SelectedEntry {
6049 worktree_id: worktree1_id,
6050 entry_id: child_file.id,
6051 }]),
6052 };
6053
6054 let result = panel.should_highlight_background_for_selection_drag(
6055 &child_file_dragged_selection,
6056 root1_entry.id,
6057 cx,
6058 );
6059 assert!(
6060 result,
6061 "Should highlight background for file with non-empty parent path"
6062 );
6063 });
6064}
6065
6066#[gpui::test]
6067async fn test_hide_root(cx: &mut gpui::TestAppContext) {
6068 init_test(cx);
6069
6070 let fs = FakeFs::new(cx.executor());
6071 fs.insert_tree(
6072 "/root1",
6073 json!({
6074 "dir1": {
6075 "file1.txt": "content",
6076 "file2.txt": "content",
6077 },
6078 "dir2": {
6079 "file3.txt": "content",
6080 },
6081 "file4.txt": "content",
6082 }),
6083 )
6084 .await;
6085
6086 fs.insert_tree(
6087 "/root2",
6088 json!({
6089 "dir3": {
6090 "file5.txt": "content",
6091 },
6092 "file6.txt": "content",
6093 }),
6094 )
6095 .await;
6096
6097 // Test 1: Single worktree with hide_root = false
6098 {
6099 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6100 let workspace =
6101 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6102 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6103
6104 cx.update(|_, cx| {
6105 let settings = *ProjectPanelSettings::get_global(cx);
6106 ProjectPanelSettings::override_global(
6107 ProjectPanelSettings {
6108 hide_root: false,
6109 ..settings
6110 },
6111 cx,
6112 );
6113 });
6114
6115 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6116
6117 #[rustfmt::skip]
6118 assert_eq!(
6119 visible_entries_as_strings(&panel, 0..10, cx),
6120 &[
6121 "v root1",
6122 " > dir1",
6123 " > dir2",
6124 " file4.txt",
6125 ],
6126 "With hide_root=false and single worktree, root should be visible"
6127 );
6128 }
6129
6130 // Test 2: Single worktree with hide_root = true
6131 {
6132 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6133 let workspace =
6134 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6135 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6136
6137 // Set hide_root to true
6138 cx.update(|_, cx| {
6139 let settings = *ProjectPanelSettings::get_global(cx);
6140 ProjectPanelSettings::override_global(
6141 ProjectPanelSettings {
6142 hide_root: true,
6143 ..settings
6144 },
6145 cx,
6146 );
6147 });
6148
6149 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6150
6151 assert_eq!(
6152 visible_entries_as_strings(&panel, 0..10, cx),
6153 &["> dir1", "> dir2", " file4.txt",],
6154 "With hide_root=true and single worktree, root should be hidden"
6155 );
6156
6157 // Test expanding directories still works without root
6158 toggle_expand_dir(&panel, "root1/dir1", cx);
6159 assert_eq!(
6160 visible_entries_as_strings(&panel, 0..10, cx),
6161 &[
6162 "v dir1 <== selected",
6163 " file1.txt",
6164 " file2.txt",
6165 "> dir2",
6166 " file4.txt",
6167 ],
6168 "Should be able to expand directories even when root is hidden"
6169 );
6170 }
6171
6172 // Test 3: Multiple worktrees with hide_root = true
6173 {
6174 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6175 let workspace =
6176 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6177 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6178
6179 // Set hide_root to true
6180 cx.update(|_, cx| {
6181 let settings = *ProjectPanelSettings::get_global(cx);
6182 ProjectPanelSettings::override_global(
6183 ProjectPanelSettings {
6184 hide_root: true,
6185 ..settings
6186 },
6187 cx,
6188 );
6189 });
6190
6191 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6192
6193 assert_eq!(
6194 visible_entries_as_strings(&panel, 0..10, cx),
6195 &[
6196 "v root1",
6197 " > dir1",
6198 " > dir2",
6199 " file4.txt",
6200 "v root2",
6201 " > dir3",
6202 " file6.txt",
6203 ],
6204 "With hide_root=true and multiple worktrees, roots should still be visible"
6205 );
6206 }
6207
6208 // Test 4: Multiple worktrees with hide_root = false
6209 {
6210 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6211 let workspace =
6212 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6213 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6214
6215 cx.update(|_, cx| {
6216 let settings = *ProjectPanelSettings::get_global(cx);
6217 ProjectPanelSettings::override_global(
6218 ProjectPanelSettings {
6219 hide_root: false,
6220 ..settings
6221 },
6222 cx,
6223 );
6224 });
6225
6226 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6227
6228 assert_eq!(
6229 visible_entries_as_strings(&panel, 0..10, cx),
6230 &[
6231 "v root1",
6232 " > dir1",
6233 " > dir2",
6234 " file4.txt",
6235 "v root2",
6236 " > dir3",
6237 " file6.txt",
6238 ],
6239 "With hide_root=false and multiple worktrees, roots should be visible"
6240 );
6241 }
6242}
6243
6244#[gpui::test]
6245async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
6246 init_test_with_editor(cx);
6247
6248 let fs = FakeFs::new(cx.executor());
6249 fs.insert_tree(
6250 "/root",
6251 json!({
6252 "file1.txt": "content of file1",
6253 "file2.txt": "content of file2",
6254 "dir1": {
6255 "file3.txt": "content of file3"
6256 }
6257 }),
6258 )
6259 .await;
6260
6261 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6262 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6263 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6264 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6265
6266 let file1_path = path!("root/file1.txt");
6267 let file2_path = path!("root/file2.txt");
6268 select_path_with_mark(&panel, file1_path, cx);
6269 select_path_with_mark(&panel, file2_path, cx);
6270
6271 panel.update_in(cx, |panel, window, cx| {
6272 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
6273 });
6274 cx.executor().run_until_parked();
6275
6276 workspace
6277 .update(cx, |workspace, _, cx| {
6278 let active_items = workspace
6279 .panes()
6280 .iter()
6281 .filter_map(|pane| pane.read(cx).active_item())
6282 .collect::<Vec<_>>();
6283 assert_eq!(active_items.len(), 1);
6284 let diff_view = active_items
6285 .into_iter()
6286 .next()
6287 .unwrap()
6288 .downcast::<FileDiffView>()
6289 .expect("Open item should be an FileDiffView");
6290 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
6291 assert_eq!(
6292 diff_view.tab_tooltip_text(cx).unwrap(),
6293 format!("{} ↔ {}", file1_path, file2_path)
6294 );
6295 })
6296 .unwrap();
6297
6298 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
6299 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
6300 let worktree_id = panel.update(cx, |panel, cx| {
6301 panel
6302 .project
6303 .read(cx)
6304 .worktrees(cx)
6305 .next()
6306 .unwrap()
6307 .read(cx)
6308 .id()
6309 });
6310
6311 let expected_entries = [
6312 SelectedEntry {
6313 worktree_id,
6314 entry_id: file1_entry_id,
6315 },
6316 SelectedEntry {
6317 worktree_id,
6318 entry_id: file2_entry_id,
6319 },
6320 ];
6321 panel.update(cx, |panel, _cx| {
6322 assert_eq!(
6323 &panel.marked_entries, &expected_entries,
6324 "Should keep marked entries after comparison"
6325 );
6326 });
6327
6328 panel.update(cx, |panel, cx| {
6329 panel.project.update(cx, |_, cx| {
6330 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
6331 })
6332 });
6333
6334 panel.update(cx, |panel, _cx| {
6335 assert_eq!(
6336 &panel.marked_entries, &expected_entries,
6337 "Marked entries should persist after focusing back on the project panel"
6338 );
6339 });
6340}
6341
6342#[gpui::test]
6343async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
6344 init_test_with_editor(cx);
6345
6346 let fs = FakeFs::new(cx.executor());
6347 fs.insert_tree(
6348 "/root",
6349 json!({
6350 "file1.txt": "content of file1",
6351 "file2.txt": "content of file2",
6352 "dir1": {},
6353 "dir2": {
6354 "file3.txt": "content of file3"
6355 }
6356 }),
6357 )
6358 .await;
6359
6360 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6361 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6362 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6363 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6364
6365 // Test 1: When only one file is selected, there should be no compare option
6366 select_path(&panel, "root/file1.txt", cx);
6367
6368 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6369 assert_eq!(
6370 selected_files, None,
6371 "Should not have compare option when only one file is selected"
6372 );
6373
6374 // Test 2: When multiple files are selected, there should be a compare option
6375 select_path_with_mark(&panel, "root/file1.txt", cx);
6376 select_path_with_mark(&panel, "root/file2.txt", cx);
6377
6378 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6379 assert!(
6380 selected_files.is_some(),
6381 "Should have files selected for comparison"
6382 );
6383 if let Some((file1, file2)) = selected_files {
6384 assert!(
6385 file1.to_string_lossy().ends_with("file1.txt")
6386 && file2.to_string_lossy().ends_with("file2.txt"),
6387 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
6388 );
6389 }
6390
6391 // Test 3: Selecting a directory shouldn't count as a comparable file
6392 select_path_with_mark(&panel, "root/dir1", cx);
6393
6394 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6395 assert!(
6396 selected_files.is_some(),
6397 "Directory selection should not affect comparable files"
6398 );
6399 if let Some((file1, file2)) = selected_files {
6400 assert!(
6401 file1.to_string_lossy().ends_with("file1.txt")
6402 && file2.to_string_lossy().ends_with("file2.txt"),
6403 "Selecting a directory should not affect the number of comparable files"
6404 );
6405 }
6406
6407 // Test 4: Selecting one more file
6408 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
6409
6410 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
6411 assert!(
6412 selected_files.is_some(),
6413 "Directory selection should not affect comparable files"
6414 );
6415 if let Some((file1, file2)) = selected_files {
6416 assert!(
6417 file1.to_string_lossy().ends_with("file2.txt")
6418 && file2.to_string_lossy().ends_with("file3.txt"),
6419 "Selecting a directory should not affect the number of comparable files"
6420 );
6421 }
6422}
6423
6424fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
6425 let path = path.as_ref();
6426 panel.update(cx, |panel, cx| {
6427 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6428 let worktree = worktree.read(cx);
6429 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6430 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6431 panel.selection = Some(crate::SelectedEntry {
6432 worktree_id: worktree.id(),
6433 entry_id,
6434 });
6435 return;
6436 }
6437 }
6438 panic!("no worktree for path {:?}", path);
6439 });
6440}
6441
6442fn select_path_with_mark(
6443 panel: &Entity<ProjectPanel>,
6444 path: impl AsRef<Path>,
6445 cx: &mut VisualTestContext,
6446) {
6447 let path = path.as_ref();
6448 panel.update(cx, |panel, cx| {
6449 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6450 let worktree = worktree.read(cx);
6451 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6452 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6453 let entry = crate::SelectedEntry {
6454 worktree_id: worktree.id(),
6455 entry_id,
6456 };
6457 if !panel.marked_entries.contains(&entry) {
6458 panel.marked_entries.push(entry);
6459 }
6460 panel.selection = Some(entry);
6461 return;
6462 }
6463 }
6464 panic!("no worktree for path {:?}", path);
6465 });
6466}
6467
6468fn find_project_entry(
6469 panel: &Entity<ProjectPanel>,
6470 path: impl AsRef<Path>,
6471 cx: &mut VisualTestContext,
6472) -> Option<ProjectEntryId> {
6473 let path = path.as_ref();
6474 panel.update(cx, |panel, cx| {
6475 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6476 let worktree = worktree.read(cx);
6477 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6478 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6479 }
6480 }
6481 panic!("no worktree for path {path:?}");
6482 })
6483}
6484
6485fn visible_entries_as_strings(
6486 panel: &Entity<ProjectPanel>,
6487 range: Range<usize>,
6488 cx: &mut VisualTestContext,
6489) -> Vec<String> {
6490 let mut result = Vec::new();
6491 let mut project_entries = HashSet::default();
6492 let mut has_editor = false;
6493
6494 panel.update_in(cx, |panel, window, cx| {
6495 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
6496 if details.is_editing {
6497 assert!(!has_editor, "duplicate editor entry");
6498 has_editor = true;
6499 } else {
6500 assert!(
6501 project_entries.insert(project_entry),
6502 "duplicate project entry {:?} {:?}",
6503 project_entry,
6504 details
6505 );
6506 }
6507
6508 let indent = " ".repeat(details.depth);
6509 let icon = if details.kind.is_dir() {
6510 if details.is_expanded { "v " } else { "> " }
6511 } else {
6512 " "
6513 };
6514 #[cfg(windows)]
6515 let filename = details.filename.replace("\\", "/");
6516 #[cfg(not(windows))]
6517 let filename = details.filename;
6518 let name = if details.is_editing {
6519 format!("[EDITOR: '{}']", filename)
6520 } else if details.is_processing {
6521 format!("[PROCESSING: '{}']", filename)
6522 } else {
6523 filename
6524 };
6525 let selected = if details.is_selected {
6526 " <== selected"
6527 } else {
6528 ""
6529 };
6530 let marked = if details.is_marked {
6531 " <== marked"
6532 } else {
6533 ""
6534 };
6535
6536 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6537 });
6538 });
6539
6540 result
6541}
6542
6543fn init_test(cx: &mut TestAppContext) {
6544 cx.update(|cx| {
6545 let settings_store = SettingsStore::test(cx);
6546 cx.set_global(settings_store);
6547 init_settings(cx);
6548 theme::init(theme::LoadThemes::JustBase, cx);
6549 language::init(cx);
6550 editor::init_settings(cx);
6551 crate::init(cx);
6552 workspace::init_settings(cx);
6553 client::init_settings(cx);
6554 Project::init_settings(cx);
6555
6556 cx.update_global::<SettingsStore, _>(|store, cx| {
6557 store.update_user_settings(cx, |settings| {
6558 settings
6559 .project_panel
6560 .get_or_insert_default()
6561 .auto_fold_dirs = Some(false);
6562 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
6563 });
6564 });
6565 });
6566}
6567
6568fn init_test_with_editor(cx: &mut TestAppContext) {
6569 cx.update(|cx| {
6570 let app_state = AppState::test(cx);
6571 theme::init(theme::LoadThemes::JustBase, cx);
6572 init_settings(cx);
6573 language::init(cx);
6574 editor::init(cx);
6575 crate::init(cx);
6576 workspace::init(app_state, cx);
6577 Project::init_settings(cx);
6578
6579 cx.update_global::<SettingsStore, _>(|store, cx| {
6580 store.update_user_settings(cx, |settings| {
6581 settings
6582 .project_panel
6583 .get_or_insert_default()
6584 .auto_fold_dirs = Some(false);
6585 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
6586 });
6587 });
6588 });
6589}
6590
6591fn ensure_single_file_is_opened(
6592 window: &WindowHandle<Workspace>,
6593 expected_path: &str,
6594 cx: &mut TestAppContext,
6595) {
6596 window
6597 .update(cx, |workspace, _, cx| {
6598 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6599 assert_eq!(worktrees.len(), 1);
6600 let worktree_id = worktrees[0].read(cx).id();
6601
6602 let open_project_paths = workspace
6603 .panes()
6604 .iter()
6605 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6606 .collect::<Vec<_>>();
6607 assert_eq!(
6608 open_project_paths,
6609 vec![ProjectPath {
6610 worktree_id,
6611 path: Arc::from(Path::new(expected_path))
6612 }],
6613 "Should have opened file, selected in project panel"
6614 );
6615 })
6616 .unwrap();
6617}
6618
6619fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6620 assert!(
6621 !cx.has_pending_prompt(),
6622 "Should have no prompts before the deletion"
6623 );
6624 panel.update_in(cx, |panel, window, cx| {
6625 panel.delete(&Delete { skip_prompt: false }, window, cx)
6626 });
6627 assert!(
6628 cx.has_pending_prompt(),
6629 "Should have a prompt after the deletion"
6630 );
6631 cx.simulate_prompt_answer("Delete");
6632 assert!(
6633 !cx.has_pending_prompt(),
6634 "Should have no prompts after prompt was replied to"
6635 );
6636 cx.executor().run_until_parked();
6637}
6638
6639fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
6640 assert!(
6641 !cx.has_pending_prompt(),
6642 "Should have no prompts before the deletion"
6643 );
6644 panel.update_in(cx, |panel, window, cx| {
6645 panel.delete(&Delete { skip_prompt: true }, window, cx)
6646 });
6647 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
6648 cx.executor().run_until_parked();
6649}
6650
6651fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
6652 assert!(
6653 !cx.has_pending_prompt(),
6654 "Should have no prompts after deletion operation closes the file"
6655 );
6656 workspace
6657 .read_with(cx, |workspace, cx| {
6658 let open_project_paths = workspace
6659 .panes()
6660 .iter()
6661 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6662 .collect::<Vec<_>>();
6663 assert!(
6664 open_project_paths.is_empty(),
6665 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
6666 );
6667 })
6668 .unwrap();
6669}
6670
6671struct TestProjectItemView {
6672 focus_handle: FocusHandle,
6673 path: ProjectPath,
6674}
6675
6676struct TestProjectItem {
6677 path: ProjectPath,
6678}
6679
6680impl project::ProjectItem for TestProjectItem {
6681 fn try_open(
6682 _project: &Entity<Project>,
6683 path: &ProjectPath,
6684 cx: &mut App,
6685 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
6686 let path = path.clone();
6687 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
6688 }
6689
6690 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
6691 None
6692 }
6693
6694 fn project_path(&self, _: &App) -> Option<ProjectPath> {
6695 Some(self.path.clone())
6696 }
6697
6698 fn is_dirty(&self) -> bool {
6699 false
6700 }
6701}
6702
6703impl ProjectItem for TestProjectItemView {
6704 type Item = TestProjectItem;
6705
6706 fn for_project_item(
6707 _: Entity<Project>,
6708 _: Option<&Pane>,
6709 project_item: Entity<Self::Item>,
6710 _: &mut Window,
6711 cx: &mut Context<Self>,
6712 ) -> Self
6713 where
6714 Self: Sized,
6715 {
6716 Self {
6717 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
6718 focus_handle: cx.focus_handle(),
6719 }
6720 }
6721}
6722
6723impl Item for TestProjectItemView {
6724 type Event = ();
6725
6726 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
6727 "Test".into()
6728 }
6729}
6730
6731impl EventEmitter<()> for TestProjectItemView {}
6732
6733impl Focusable for TestProjectItemView {
6734 fn focus_handle(&self, _: &App) -> FocusHandle {
6735 self.focus_handle.clone()
6736 }
6737}
6738
6739impl Render for TestProjectItemView {
6740 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
6741 Empty
6742 }
6743}