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