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