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