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