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