1use super::*;
2use collections::HashSet;
3use editor::MultiBufferOffset;
4use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
5use menu::Cancel;
6use pretty_assertions::assert_eq;
7use project::FakeFs;
8use serde_json::json;
9use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
10use std::path::{Path, PathBuf};
11use util::{path, paths::PathStyle, rel_path::rel_path};
12use workspace::{
13 AppState, ItemHandle, Pane,
14 item::{Item, ProjectItem},
15 register_project_item,
16};
17
18#[gpui::test]
19async fn test_visible_list(cx: &mut gpui::TestAppContext) {
20 init_test(cx);
21
22 let fs = FakeFs::new(cx.executor());
23 fs.insert_tree(
24 "/root1",
25 json!({
26 ".dockerignore": "",
27 ".git": {
28 "HEAD": "",
29 },
30 "a": {
31 "0": { "q": "", "r": "", "s": "" },
32 "1": { "t": "", "u": "" },
33 "2": { "v": "", "w": "", "x": "", "y": "" },
34 },
35 "b": {
36 "3": { "Q": "" },
37 "4": { "R": "", "S": "", "T": "", "U": "" },
38 },
39 "C": {
40 "5": {},
41 "6": { "V": "", "W": "" },
42 "7": { "X": "" },
43 "8": { "Y": {}, "Z": "" }
44 }
45 }),
46 )
47 .await;
48 fs.insert_tree(
49 "/root2",
50 json!({
51 "d": {
52 "9": ""
53 },
54 "e": {}
55 }),
56 )
57 .await;
58
59 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
60 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
61 let cx = &mut VisualTestContext::from_window(*workspace, cx);
62 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
63 cx.run_until_parked();
64 assert_eq!(
65 visible_entries_as_strings(&panel, 0..50, cx),
66 &[
67 "v root1",
68 " > .git",
69 " > a",
70 " > b",
71 " > C",
72 " .dockerignore",
73 "v root2",
74 " > d",
75 " > e",
76 ]
77 );
78
79 toggle_expand_dir(&panel, "root1/b", cx);
80 assert_eq!(
81 visible_entries_as_strings(&panel, 0..50, cx),
82 &[
83 "v root1",
84 " > .git",
85 " > a",
86 " v b <== selected",
87 " > 3",
88 " > 4",
89 " > C",
90 " .dockerignore",
91 "v root2",
92 " > d",
93 " > e",
94 ]
95 );
96
97 assert_eq!(
98 visible_entries_as_strings(&panel, 6..9, cx),
99 &[
100 //
101 " > C",
102 " .dockerignore",
103 "v root2",
104 ]
105 );
106}
107
108#[gpui::test]
109async fn test_opening_file(cx: &mut gpui::TestAppContext) {
110 init_test_with_editor(cx);
111
112 let fs = FakeFs::new(cx.executor());
113 fs.insert_tree(
114 path!("/src"),
115 json!({
116 "test": {
117 "first.rs": "// First Rust file",
118 "second.rs": "// Second Rust file",
119 "third.rs": "// Third Rust file",
120 }
121 }),
122 )
123 .await;
124
125 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
126 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
127 let cx = &mut VisualTestContext::from_window(*workspace, cx);
128 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
129 cx.run_until_parked();
130
131 toggle_expand_dir(&panel, "src/test", cx);
132 select_path(&panel, "src/test/first.rs", cx);
133 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
134 cx.executor().run_until_parked();
135 assert_eq!(
136 visible_entries_as_strings(&panel, 0..10, cx),
137 &[
138 "v src",
139 " v test",
140 " first.rs <== selected <== marked",
141 " second.rs",
142 " third.rs"
143 ]
144 );
145 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
146
147 select_path(&panel, "src/test/second.rs", cx);
148 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
149 cx.executor().run_until_parked();
150 assert_eq!(
151 visible_entries_as_strings(&panel, 0..10, cx),
152 &[
153 "v src",
154 " v test",
155 " first.rs",
156 " second.rs <== selected <== marked",
157 " third.rs"
158 ]
159 );
160 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
161}
162
163#[gpui::test]
164async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
165 init_test(cx);
166 cx.update(|cx| {
167 cx.update_global::<SettingsStore, _>(|store, cx| {
168 store.update_user_settings(cx, |settings| {
169 settings.project.worktree.file_scan_exclusions =
170 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
171 });
172 });
173 });
174
175 let fs = FakeFs::new(cx.background_executor.clone());
176 fs.insert_tree(
177 "/root1",
178 json!({
179 ".dockerignore": "",
180 ".git": {
181 "HEAD": "",
182 },
183 "a": {
184 "0": { "q": "", "r": "", "s": "" },
185 "1": { "t": "", "u": "" },
186 "2": { "v": "", "w": "", "x": "", "y": "" },
187 },
188 "b": {
189 "3": { "Q": "" },
190 "4": { "R": "", "S": "", "T": "", "U": "" },
191 },
192 "C": {
193 "5": {},
194 "6": { "V": "", "W": "" },
195 "7": { "X": "" },
196 "8": { "Y": {}, "Z": "" }
197 }
198 }),
199 )
200 .await;
201 fs.insert_tree(
202 "/root2",
203 json!({
204 "d": {
205 "4": ""
206 },
207 "e": {}
208 }),
209 )
210 .await;
211
212 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
213 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
214 let cx = &mut VisualTestContext::from_window(*workspace, cx);
215 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
216 cx.run_until_parked();
217 assert_eq!(
218 visible_entries_as_strings(&panel, 0..50, cx),
219 &[
220 "v root1",
221 " > a",
222 " > b",
223 " > C",
224 " .dockerignore",
225 "v root2",
226 " > d",
227 " > e",
228 ]
229 );
230
231 toggle_expand_dir(&panel, "root1/b", cx);
232 assert_eq!(
233 visible_entries_as_strings(&panel, 0..50, cx),
234 &[
235 "v root1",
236 " > a",
237 " v b <== selected",
238 " > 3",
239 " > C",
240 " .dockerignore",
241 "v root2",
242 " > d",
243 " > e",
244 ]
245 );
246
247 toggle_expand_dir(&panel, "root2/d", cx);
248 assert_eq!(
249 visible_entries_as_strings(&panel, 0..50, cx),
250 &[
251 "v root1",
252 " > a",
253 " v b",
254 " > 3",
255 " > C",
256 " .dockerignore",
257 "v root2",
258 " v d <== selected",
259 " > e",
260 ]
261 );
262
263 toggle_expand_dir(&panel, "root2/e", cx);
264 assert_eq!(
265 visible_entries_as_strings(&panel, 0..50, cx),
266 &[
267 "v root1",
268 " > a",
269 " v b",
270 " > 3",
271 " > C",
272 " .dockerignore",
273 "v root2",
274 " v d",
275 " v e <== selected",
276 ]
277 );
278}
279
280#[gpui::test]
281async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
282 init_test(cx);
283
284 let fs = FakeFs::new(cx.executor());
285 fs.insert_tree(
286 path!("/root1"),
287 json!({
288 "dir_1": {
289 "nested_dir_1": {
290 "nested_dir_2": {
291 "nested_dir_3": {
292 "file_a.java": "// File contents",
293 "file_b.java": "// File contents",
294 "file_c.java": "// File contents",
295 "nested_dir_4": {
296 "nested_dir_5": {
297 "file_d.java": "// File contents",
298 }
299 }
300 }
301 }
302 }
303 }
304 }),
305 )
306 .await;
307 fs.insert_tree(
308 path!("/root2"),
309 json!({
310 "dir_2": {
311 "file_1.java": "// File contents",
312 }
313 }),
314 )
315 .await;
316
317 // Test 1: Multiple worktrees with auto_fold_dirs = true
318 let project = Project::test(
319 fs.clone(),
320 [path!("/root1").as_ref(), path!("/root2").as_ref()],
321 cx,
322 )
323 .await;
324 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
325 let cx = &mut VisualTestContext::from_window(*workspace, cx);
326 cx.update(|_, cx| {
327 let settings = *ProjectPanelSettings::get_global(cx);
328 ProjectPanelSettings::override_global(
329 ProjectPanelSettings {
330 auto_fold_dirs: true,
331 sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
332 ..settings
333 },
334 cx,
335 );
336 });
337 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
338 cx.run_until_parked();
339 assert_eq!(
340 visible_entries_as_strings(&panel, 0..10, cx),
341 &[
342 "v root1",
343 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
344 "v root2",
345 " > dir_2",
346 ]
347 );
348
349 toggle_expand_dir(
350 &panel,
351 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
352 cx,
353 );
354 assert_eq!(
355 visible_entries_as_strings(&panel, 0..10, cx),
356 &[
357 "v root1",
358 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
359 " > nested_dir_4/nested_dir_5",
360 " file_a.java",
361 " file_b.java",
362 " file_c.java",
363 "v root2",
364 " > dir_2",
365 ]
366 );
367
368 toggle_expand_dir(
369 &panel,
370 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
371 cx,
372 );
373 assert_eq!(
374 visible_entries_as_strings(&panel, 0..10, cx),
375 &[
376 "v root1",
377 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
378 " v nested_dir_4/nested_dir_5 <== selected",
379 " file_d.java",
380 " file_a.java",
381 " file_b.java",
382 " file_c.java",
383 "v root2",
384 " > dir_2",
385 ]
386 );
387 toggle_expand_dir(&panel, "root2/dir_2", cx);
388 assert_eq!(
389 visible_entries_as_strings(&panel, 0..10, cx),
390 &[
391 "v root1",
392 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
393 " v nested_dir_4/nested_dir_5",
394 " file_d.java",
395 " file_a.java",
396 " file_b.java",
397 " file_c.java",
398 "v root2",
399 " v dir_2 <== selected",
400 " file_1.java",
401 ]
402 );
403
404 // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
405 {
406 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
407 let workspace =
408 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
409 let cx = &mut VisualTestContext::from_window(*workspace, cx);
410 cx.update(|_, cx| {
411 let settings = *ProjectPanelSettings::get_global(cx);
412 ProjectPanelSettings::override_global(
413 ProjectPanelSettings {
414 auto_fold_dirs: true,
415 hide_root: true,
416 ..settings
417 },
418 cx,
419 );
420 });
421 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
422 cx.run_until_parked();
423 assert_eq!(
424 visible_entries_as_strings(&panel, 0..10, cx),
425 &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
426 "Single worktree with hide_root=true should hide root and show auto-folded paths"
427 );
428
429 toggle_expand_dir(
430 &panel,
431 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
432 cx,
433 );
434 assert_eq!(
435 visible_entries_as_strings(&panel, 0..10, cx),
436 &[
437 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
438 " > nested_dir_4/nested_dir_5",
439 " file_a.java",
440 " file_b.java",
441 " file_c.java",
442 ],
443 "Expanded auto-folded path with hidden root should show contents without root prefix"
444 );
445
446 toggle_expand_dir(
447 &panel,
448 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
449 cx,
450 );
451 assert_eq!(
452 visible_entries_as_strings(&panel, 0..10, cx),
453 &[
454 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
455 " v nested_dir_4/nested_dir_5 <== selected",
456 " file_d.java",
457 " file_a.java",
458 " file_b.java",
459 " file_c.java",
460 ],
461 "Nested expansion with hidden root should maintain proper indentation"
462 );
463 }
464}
465
466#[gpui::test(iterations = 30)]
467async fn test_editing_files(cx: &mut gpui::TestAppContext) {
468 init_test(cx);
469
470 let fs = FakeFs::new(cx.executor());
471 fs.insert_tree(
472 "/root1",
473 json!({
474 ".dockerignore": "",
475 ".git": {
476 "HEAD": "",
477 },
478 "a": {
479 "0": { "q": "", "r": "", "s": "" },
480 "1": { "t": "", "u": "" },
481 "2": { "v": "", "w": "", "x": "", "y": "" },
482 },
483 "b": {
484 "3": { "Q": "" },
485 "4": { "R": "", "S": "", "T": "", "U": "" },
486 },
487 "C": {
488 "5": {},
489 "6": { "V": "", "W": "" },
490 "7": { "X": "" },
491 "8": { "Y": {}, "Z": "" }
492 }
493 }),
494 )
495 .await;
496 fs.insert_tree(
497 "/root2",
498 json!({
499 "d": {
500 "9": ""
501 },
502 "e": {}
503 }),
504 )
505 .await;
506
507 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
508 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
509 let cx = &mut VisualTestContext::from_window(*workspace, cx);
510 let panel = workspace
511 .update(cx, |workspace, window, cx| {
512 let panel = ProjectPanel::new(workspace, window, cx);
513 workspace.add_panel(panel.clone(), window, cx);
514 panel
515 })
516 .unwrap();
517 cx.run_until_parked();
518
519 select_path(&panel, "root1", cx);
520 assert_eq!(
521 visible_entries_as_strings(&panel, 0..10, cx),
522 &[
523 "v root1 <== selected",
524 " > .git",
525 " > a",
526 " > b",
527 " > C",
528 " .dockerignore",
529 "v root2",
530 " > d",
531 " > e",
532 ]
533 );
534
535 // Add a file with the root folder selected. The filename editor is placed
536 // before the first file in the root folder.
537 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
538 cx.run_until_parked();
539 panel.update_in(cx, |panel, window, cx| {
540 assert!(panel.filename_editor.read(cx).is_focused(window));
541 });
542 assert_eq!(
543 visible_entries_as_strings(&panel, 0..10, cx),
544 &[
545 "v root1",
546 " > .git",
547 " > a",
548 " > b",
549 " > C",
550 " [EDITOR: ''] <== selected",
551 " .dockerignore",
552 "v root2",
553 " > d",
554 " > e",
555 ]
556 );
557
558 let confirm = panel.update_in(cx, |panel, window, cx| {
559 panel.filename_editor.update(cx, |editor, cx| {
560 editor.set_text("the-new-filename", window, cx)
561 });
562 panel.confirm_edit(true, window, cx).unwrap()
563 });
564 assert_eq!(
565 visible_entries_as_strings(&panel, 0..10, cx),
566 &[
567 "v root1",
568 " > .git",
569 " > a",
570 " > b",
571 " > C",
572 " [PROCESSING: 'the-new-filename'] <== selected",
573 " .dockerignore",
574 "v root2",
575 " > d",
576 " > e",
577 ]
578 );
579
580 confirm.await.unwrap();
581 cx.run_until_parked();
582 assert_eq!(
583 visible_entries_as_strings(&panel, 0..10, cx),
584 &[
585 "v root1",
586 " > .git",
587 " > a",
588 " > b",
589 " > C",
590 " .dockerignore",
591 " the-new-filename <== selected <== marked",
592 "v root2",
593 " > d",
594 " > e",
595 ]
596 );
597
598 select_path(&panel, "root1/b", cx);
599 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
600 cx.run_until_parked();
601 assert_eq!(
602 visible_entries_as_strings(&panel, 0..10, cx),
603 &[
604 "v root1",
605 " > .git",
606 " > a",
607 " v b",
608 " > 3",
609 " > 4",
610 " [EDITOR: ''] <== selected",
611 " > C",
612 " .dockerignore",
613 " the-new-filename",
614 ]
615 );
616
617 panel
618 .update_in(cx, |panel, window, cx| {
619 panel.filename_editor.update(cx, |editor, cx| {
620 editor.set_text("another-filename.txt", window, cx)
621 });
622 panel.confirm_edit(true, window, cx).unwrap()
623 })
624 .await
625 .unwrap();
626 cx.run_until_parked();
627 assert_eq!(
628 visible_entries_as_strings(&panel, 0..10, cx),
629 &[
630 "v root1",
631 " > .git",
632 " > a",
633 " v b",
634 " > 3",
635 " > 4",
636 " another-filename.txt <== selected <== marked",
637 " > C",
638 " .dockerignore",
639 " the-new-filename",
640 ]
641 );
642
643 select_path(&panel, "root1/b/another-filename.txt", cx);
644 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
645 assert_eq!(
646 visible_entries_as_strings(&panel, 0..10, cx),
647 &[
648 "v root1",
649 " > .git",
650 " > a",
651 " v b",
652 " > 3",
653 " > 4",
654 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
655 " > C",
656 " .dockerignore",
657 " the-new-filename",
658 ]
659 );
660
661 let confirm = panel.update_in(cx, |panel, window, cx| {
662 panel.filename_editor.update(cx, |editor, cx| {
663 let file_name_selections = editor
664 .selections
665 .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
666 assert_eq!(
667 file_name_selections.len(),
668 1,
669 "File editing should have a single selection, but got: {file_name_selections:?}"
670 );
671 let file_name_selection = &file_name_selections[0];
672 assert_eq!(
673 file_name_selection.start,
674 MultiBufferOffset(0),
675 "Should select the file name from the start"
676 );
677 assert_eq!(
678 file_name_selection.end,
679 MultiBufferOffset("another-filename".len()),
680 "Should not select file extension"
681 );
682
683 editor.set_text("a-different-filename.tar.gz", window, cx)
684 });
685 panel.confirm_edit(true, window, cx).unwrap()
686 });
687 assert_eq!(
688 visible_entries_as_strings(&panel, 0..10, cx),
689 &[
690 "v root1",
691 " > .git",
692 " > a",
693 " v b",
694 " > 3",
695 " > 4",
696 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
697 " > C",
698 " .dockerignore",
699 " the-new-filename",
700 ]
701 );
702
703 confirm.await.unwrap();
704 cx.run_until_parked();
705 assert_eq!(
706 visible_entries_as_strings(&panel, 0..10, cx),
707 &[
708 "v root1",
709 " > .git",
710 " > a",
711 " v b",
712 " > 3",
713 " > 4",
714 " a-different-filename.tar.gz <== selected",
715 " > C",
716 " .dockerignore",
717 " the-new-filename",
718 ]
719 );
720
721 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
722 assert_eq!(
723 visible_entries_as_strings(&panel, 0..10, cx),
724 &[
725 "v root1",
726 " > .git",
727 " > a",
728 " v b",
729 " > 3",
730 " > 4",
731 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
732 " > C",
733 " .dockerignore",
734 " the-new-filename",
735 ]
736 );
737
738 panel.update_in(cx, |panel, window, cx| {
739 panel.filename_editor.update(cx, |editor, cx| {
740 let file_name_selections = editor.selections.all::<MultiBufferOffset>(&editor.display_snapshot(cx));
741 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
742 let file_name_selection = &file_name_selections[0];
743 assert_eq!(file_name_selection.start, MultiBufferOffset(0), "Should select the file name from the start");
744 assert_eq!(file_name_selection.end, MultiBufferOffset("a-different-filename.tar".len()), "Should not select file extension, but still may select anything up to the last dot..");
745
746 });
747 panel.cancel(&menu::Cancel, window, cx)
748 });
749 cx.run_until_parked();
750 panel.update_in(cx, |panel, window, cx| {
751 panel.new_directory(&NewDirectory, window, cx)
752 });
753 cx.run_until_parked();
754 assert_eq!(
755 visible_entries_as_strings(&panel, 0..10, cx),
756 &[
757 "v root1",
758 " > .git",
759 " > a",
760 " v b",
761 " > [EDITOR: ''] <== selected",
762 " > 3",
763 " > 4",
764 " a-different-filename.tar.gz",
765 " > C",
766 " .dockerignore",
767 ]
768 );
769
770 let confirm = panel.update_in(cx, |panel, window, cx| {
771 panel
772 .filename_editor
773 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
774 panel.confirm_edit(true, window, cx).unwrap()
775 });
776 panel.update_in(cx, |panel, window, cx| {
777 panel.select_next(&Default::default(), window, cx)
778 });
779 assert_eq!(
780 visible_entries_as_strings(&panel, 0..10, cx),
781 &[
782 "v root1",
783 " > .git",
784 " > a",
785 " v b",
786 " > [PROCESSING: 'new-dir']",
787 " > 3 <== selected",
788 " > 4",
789 " a-different-filename.tar.gz",
790 " > C",
791 " .dockerignore",
792 ]
793 );
794
795 confirm.await.unwrap();
796 cx.run_until_parked();
797 assert_eq!(
798 visible_entries_as_strings(&panel, 0..10, cx),
799 &[
800 "v root1",
801 " > .git",
802 " > a",
803 " v b",
804 " > 3 <== selected",
805 " > 4",
806 " > new-dir",
807 " a-different-filename.tar.gz",
808 " > C",
809 " .dockerignore",
810 ]
811 );
812
813 panel.update_in(cx, |panel, window, cx| {
814 panel.rename(&Default::default(), window, cx)
815 });
816 cx.run_until_parked();
817 assert_eq!(
818 visible_entries_as_strings(&panel, 0..10, cx),
819 &[
820 "v root1",
821 " > .git",
822 " > a",
823 " v b",
824 " > [EDITOR: '3'] <== selected",
825 " > 4",
826 " > new-dir",
827 " a-different-filename.tar.gz",
828 " > C",
829 " .dockerignore",
830 ]
831 );
832
833 // Dismiss the rename editor when it loses focus.
834 workspace.update(cx, |_, window, _| window.blur()).unwrap();
835 assert_eq!(
836 visible_entries_as_strings(&panel, 0..10, cx),
837 &[
838 "v root1",
839 " > .git",
840 " > a",
841 " v b",
842 " > 3 <== selected",
843 " > 4",
844 " > new-dir",
845 " a-different-filename.tar.gz",
846 " > C",
847 " .dockerignore",
848 ]
849 );
850
851 // Test empty filename and filename with only whitespace
852 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
853 cx.run_until_parked();
854 assert_eq!(
855 visible_entries_as_strings(&panel, 0..10, cx),
856 &[
857 "v root1",
858 " > .git",
859 " > a",
860 " v b",
861 " v 3",
862 " [EDITOR: ''] <== selected",
863 " Q",
864 " > 4",
865 " > new-dir",
866 " a-different-filename.tar.gz",
867 ]
868 );
869 panel.update_in(cx, |panel, window, cx| {
870 panel.filename_editor.update(cx, |editor, cx| {
871 editor.set_text("", window, cx);
872 });
873 assert!(panel.confirm_edit(true, window, cx).is_none());
874 panel.filename_editor.update(cx, |editor, cx| {
875 editor.set_text(" ", window, cx);
876 });
877 assert!(panel.confirm_edit(true, window, cx).is_none());
878 panel.cancel(&menu::Cancel, window, cx);
879 });
880 cx.run_until_parked();
881 assert_eq!(
882 visible_entries_as_strings(&panel, 0..10, cx),
883 &[
884 "v root1",
885 " > .git",
886 " > a",
887 " v b",
888 " v 3 <== selected",
889 " Q",
890 " > 4",
891 " > new-dir",
892 " a-different-filename.tar.gz",
893 " > C",
894 ]
895 );
896}
897
898#[gpui::test(iterations = 10)]
899async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
900 init_test(cx);
901
902 let fs = FakeFs::new(cx.executor());
903 fs.insert_tree(
904 "/root1",
905 json!({
906 ".dockerignore": "",
907 ".git": {
908 "HEAD": "",
909 },
910 "a": {
911 "0": { "q": "", "r": "", "s": "" },
912 "1": { "t": "", "u": "" },
913 "2": { "v": "", "w": "", "x": "", "y": "" },
914 },
915 "b": {
916 "3": { "Q": "" },
917 "4": { "R": "", "S": "", "T": "", "U": "" },
918 },
919 "C": {
920 "5": {},
921 "6": { "V": "", "W": "" },
922 "7": { "X": "" },
923 "8": { "Y": {}, "Z": "" }
924 }
925 }),
926 )
927 .await;
928 fs.insert_tree(
929 "/root2",
930 json!({
931 "d": {
932 "9": ""
933 },
934 "e": {}
935 }),
936 )
937 .await;
938
939 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
940 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
941 let cx = &mut VisualTestContext::from_window(*workspace, cx);
942 let panel = workspace
943 .update(cx, |workspace, window, cx| {
944 let panel = ProjectPanel::new(workspace, window, cx);
945 workspace.add_panel(panel.clone(), window, cx);
946 panel
947 })
948 .unwrap();
949 cx.run_until_parked();
950
951 select_path(&panel, "root1", cx);
952 assert_eq!(
953 visible_entries_as_strings(&panel, 0..10, cx),
954 &[
955 "v root1 <== selected",
956 " > .git",
957 " > a",
958 " > b",
959 " > C",
960 " .dockerignore",
961 "v root2",
962 " > d",
963 " > e",
964 ]
965 );
966
967 // Add a file with the root folder selected. The filename editor is placed
968 // before the first file in the root folder.
969 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
970 cx.run_until_parked();
971 panel.update_in(cx, |panel, window, cx| {
972 assert!(panel.filename_editor.read(cx).is_focused(window));
973 });
974 cx.run_until_parked();
975 assert_eq!(
976 visible_entries_as_strings(&panel, 0..10, cx),
977 &[
978 "v root1",
979 " > .git",
980 " > a",
981 " > b",
982 " > C",
983 " [EDITOR: ''] <== selected",
984 " .dockerignore",
985 "v root2",
986 " > d",
987 " > e",
988 ]
989 );
990
991 let confirm = panel.update_in(cx, |panel, window, cx| {
992 panel.filename_editor.update(cx, |editor, cx| {
993 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
994 });
995 panel.confirm_edit(true, window, cx).unwrap()
996 });
997
998 assert_eq!(
999 visible_entries_as_strings(&panel, 0..10, cx),
1000 &[
1001 "v root1",
1002 " > .git",
1003 " > a",
1004 " > b",
1005 " > C",
1006 " [PROCESSING: 'bdir1/dir2/the-new-filename'] <== selected",
1007 " .dockerignore",
1008 "v root2",
1009 " > d",
1010 " > e",
1011 ]
1012 );
1013
1014 confirm.await.unwrap();
1015 cx.run_until_parked();
1016 assert_eq!(
1017 visible_entries_as_strings(&panel, 0..13, cx),
1018 &[
1019 "v root1",
1020 " > .git",
1021 " > a",
1022 " > b",
1023 " v bdir1",
1024 " v dir2",
1025 " the-new-filename <== selected <== marked",
1026 " > C",
1027 " .dockerignore",
1028 "v root2",
1029 " > d",
1030 " > e",
1031 ]
1032 );
1033}
1034
1035#[gpui::test]
1036async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
1037 init_test(cx);
1038
1039 let fs = FakeFs::new(cx.executor());
1040 fs.insert_tree(
1041 path!("/root1"),
1042 json!({
1043 ".dockerignore": "",
1044 ".git": {
1045 "HEAD": "",
1046 },
1047 }),
1048 )
1049 .await;
1050
1051 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
1052 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1053 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1054 let panel = workspace
1055 .update(cx, |workspace, window, cx| {
1056 let panel = ProjectPanel::new(workspace, window, cx);
1057 workspace.add_panel(panel.clone(), window, cx);
1058 panel
1059 })
1060 .unwrap();
1061 cx.run_until_parked();
1062
1063 select_path(&panel, "root1", cx);
1064 assert_eq!(
1065 visible_entries_as_strings(&panel, 0..10, cx),
1066 &["v root1 <== selected", " > .git", " .dockerignore",]
1067 );
1068
1069 // Add a file with the root folder selected. The filename editor is placed
1070 // before the first file in the root folder.
1071 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1072 cx.run_until_parked();
1073 panel.update_in(cx, |panel, window, cx| {
1074 assert!(panel.filename_editor.read(cx).is_focused(window));
1075 });
1076 assert_eq!(
1077 visible_entries_as_strings(&panel, 0..10, cx),
1078 &[
1079 "v root1",
1080 " > .git",
1081 " [EDITOR: ''] <== selected",
1082 " .dockerignore",
1083 ]
1084 );
1085
1086 let confirm = panel.update_in(cx, |panel, window, cx| {
1087 // If we want to create a subdirectory, there should be no prefix slash.
1088 panel
1089 .filename_editor
1090 .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
1091 panel.confirm_edit(true, window, cx).unwrap()
1092 });
1093
1094 assert_eq!(
1095 visible_entries_as_strings(&panel, 0..10, cx),
1096 &[
1097 "v root1",
1098 " > .git",
1099 " [PROCESSING: 'new_dir'] <== selected",
1100 " .dockerignore",
1101 ]
1102 );
1103
1104 confirm.await.unwrap();
1105 cx.run_until_parked();
1106 assert_eq!(
1107 visible_entries_as_strings(&panel, 0..10, cx),
1108 &[
1109 "v root1",
1110 " > .git",
1111 " v new_dir <== selected",
1112 " .dockerignore",
1113 ]
1114 );
1115
1116 // Test filename with whitespace
1117 select_path(&panel, "root1", cx);
1118 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1119 let confirm = panel.update_in(cx, |panel, window, cx| {
1120 // If we want to create a subdirectory, there should be no prefix slash.
1121 panel
1122 .filename_editor
1123 .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
1124 panel.confirm_edit(true, window, cx).unwrap()
1125 });
1126 confirm.await.unwrap();
1127 cx.run_until_parked();
1128 assert_eq!(
1129 visible_entries_as_strings(&panel, 0..10, cx),
1130 &[
1131 "v root1",
1132 " > .git",
1133 " v new dir 2 <== selected",
1134 " v new_dir",
1135 " .dockerignore",
1136 ]
1137 );
1138
1139 // Test filename ends with "\"
1140 #[cfg(target_os = "windows")]
1141 {
1142 select_path(&panel, "root1", cx);
1143 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
1144 let confirm = panel.update_in(cx, |panel, window, cx| {
1145 // If we want to create a subdirectory, there should be no prefix slash.
1146 panel
1147 .filename_editor
1148 .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
1149 panel.confirm_edit(true, window, cx).unwrap()
1150 });
1151 confirm.await.unwrap();
1152 cx.run_until_parked();
1153 assert_eq!(
1154 visible_entries_as_strings(&panel, 0..10, cx),
1155 &[
1156 "v root1",
1157 " > .git",
1158 " v new dir 2",
1159 " v new_dir",
1160 " v new_dir_3 <== selected",
1161 " .dockerignore",
1162 ]
1163 );
1164 }
1165}
1166
1167#[gpui::test]
1168async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1169 init_test(cx);
1170
1171 let fs = FakeFs::new(cx.executor());
1172 fs.insert_tree(
1173 "/root1",
1174 json!({
1175 "one.two.txt": "",
1176 "one.txt": ""
1177 }),
1178 )
1179 .await;
1180
1181 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1182 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1183 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1184 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1185 cx.run_until_parked();
1186
1187 panel.update_in(cx, |panel, window, cx| {
1188 panel.select_next(&Default::default(), window, cx);
1189 panel.select_next(&Default::default(), window, cx);
1190 });
1191
1192 assert_eq!(
1193 visible_entries_as_strings(&panel, 0..50, cx),
1194 &[
1195 //
1196 "v root1",
1197 " one.txt <== selected",
1198 " one.two.txt",
1199 ]
1200 );
1201
1202 // Regression test - file name is created correctly when
1203 // the copied file's name contains multiple dots.
1204 panel.update_in(cx, |panel, window, cx| {
1205 panel.copy(&Default::default(), window, cx);
1206 panel.paste(&Default::default(), window, cx);
1207 });
1208 cx.executor().run_until_parked();
1209 panel.update_in(cx, |panel, window, cx| {
1210 assert!(panel.filename_editor.read(cx).is_focused(window));
1211 });
1212 assert_eq!(
1213 visible_entries_as_strings(&panel, 0..50, cx),
1214 &[
1215 //
1216 "v root1",
1217 " one.txt",
1218 " [EDITOR: 'one copy.txt'] <== selected <== marked",
1219 " one.two.txt",
1220 ]
1221 );
1222
1223 panel.update_in(cx, |panel, window, cx| {
1224 panel.filename_editor.update(cx, |editor, cx| {
1225 let file_name_selections = editor
1226 .selections
1227 .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
1228 assert_eq!(
1229 file_name_selections.len(),
1230 1,
1231 "File editing should have a single selection, but got: {file_name_selections:?}"
1232 );
1233 let file_name_selection = &file_name_selections[0];
1234 assert_eq!(
1235 file_name_selection.start,
1236 MultiBufferOffset("one".len()),
1237 "Should select the file name disambiguation after the original file name"
1238 );
1239 assert_eq!(
1240 file_name_selection.end,
1241 MultiBufferOffset("one copy".len()),
1242 "Should select the file name disambiguation until the extension"
1243 );
1244 });
1245 assert!(panel.confirm_edit(true, window, cx).is_none());
1246 });
1247
1248 panel.update_in(cx, |panel, window, cx| {
1249 panel.paste(&Default::default(), window, cx);
1250 });
1251 cx.executor().run_until_parked();
1252 panel.update_in(cx, |panel, window, cx| {
1253 assert!(panel.filename_editor.read(cx).is_focused(window));
1254 });
1255 assert_eq!(
1256 visible_entries_as_strings(&panel, 0..50, cx),
1257 &[
1258 //
1259 "v root1",
1260 " one.txt",
1261 " one copy.txt",
1262 " [EDITOR: 'one copy 1.txt'] <== selected <== marked",
1263 " one.two.txt",
1264 ]
1265 );
1266
1267 panel.update_in(cx, |panel, window, cx| {
1268 assert!(panel.confirm_edit(true, window, cx).is_none())
1269 });
1270}
1271
1272#[gpui::test]
1273async fn test_cut_paste(cx: &mut gpui::TestAppContext) {
1274 init_test(cx);
1275
1276 let fs = FakeFs::new(cx.executor());
1277 fs.insert_tree(
1278 "/root",
1279 json!({
1280 "one.txt": "",
1281 "two.txt": "",
1282 "a": {},
1283 "b": {}
1284 }),
1285 )
1286 .await;
1287
1288 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1289 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1290 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1291 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1292 cx.run_until_parked();
1293
1294 select_path_with_mark(&panel, "root/one.txt", cx);
1295 select_path_with_mark(&panel, "root/two.txt", cx);
1296
1297 assert_eq!(
1298 visible_entries_as_strings(&panel, 0..50, cx),
1299 &[
1300 "v root",
1301 " > a",
1302 " > b",
1303 " one.txt <== marked",
1304 " two.txt <== selected <== marked",
1305 ]
1306 );
1307
1308 panel.update_in(cx, |panel, window, cx| {
1309 panel.cut(&Default::default(), window, cx);
1310 });
1311
1312 select_path(&panel, "root/a", cx);
1313
1314 panel.update_in(cx, |panel, window, cx| {
1315 panel.paste(&Default::default(), window, cx);
1316 panel.update_visible_entries(None, false, false, window, cx);
1317 });
1318 cx.executor().run_until_parked();
1319
1320 assert_eq!(
1321 visible_entries_as_strings(&panel, 0..50, cx),
1322 &[
1323 "v root",
1324 " v a",
1325 " one.txt <== marked",
1326 " two.txt <== selected <== marked",
1327 " > b",
1328 ],
1329 "Cut entries should be moved on first paste."
1330 );
1331
1332 panel.update_in(cx, |panel, window, cx| {
1333 panel.cancel(&menu::Cancel {}, window, cx)
1334 });
1335 cx.executor().run_until_parked();
1336
1337 select_path(&panel, "root/b", cx);
1338
1339 panel.update_in(cx, |panel, window, cx| {
1340 panel.paste(&Default::default(), window, cx);
1341 });
1342 cx.executor().run_until_parked();
1343
1344 assert_eq!(
1345 visible_entries_as_strings(&panel, 0..50, cx),
1346 &[
1347 "v root",
1348 " v a",
1349 " one.txt",
1350 " two.txt",
1351 " v b",
1352 " one.txt",
1353 " two.txt <== selected",
1354 ],
1355 "Cut entries should only be copied for the second paste!"
1356 );
1357}
1358
1359#[gpui::test]
1360async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1361 init_test(cx);
1362
1363 let fs = FakeFs::new(cx.executor());
1364 fs.insert_tree(
1365 "/root1",
1366 json!({
1367 "one.txt": "",
1368 "two.txt": "",
1369 "three.txt": "",
1370 "a": {
1371 "0": { "q": "", "r": "", "s": "" },
1372 "1": { "t": "", "u": "" },
1373 "2": { "v": "", "w": "", "x": "", "y": "" },
1374 },
1375 }),
1376 )
1377 .await;
1378
1379 fs.insert_tree(
1380 "/root2",
1381 json!({
1382 "one.txt": "",
1383 "two.txt": "",
1384 "four.txt": "",
1385 "b": {
1386 "3": { "Q": "" },
1387 "4": { "R": "", "S": "", "T": "", "U": "" },
1388 },
1389 }),
1390 )
1391 .await;
1392
1393 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1394 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1395 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1396 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1397 cx.run_until_parked();
1398
1399 select_path(&panel, "root1/three.txt", cx);
1400 panel.update_in(cx, |panel, window, cx| {
1401 panel.cut(&Default::default(), window, cx);
1402 });
1403
1404 select_path(&panel, "root2/one.txt", cx);
1405 panel.update_in(cx, |panel, window, cx| {
1406 panel.select_next(&Default::default(), window, cx);
1407 panel.paste(&Default::default(), window, cx);
1408 });
1409 cx.executor().run_until_parked();
1410 assert_eq!(
1411 visible_entries_as_strings(&panel, 0..50, cx),
1412 &[
1413 //
1414 "v root1",
1415 " > a",
1416 " one.txt",
1417 " two.txt",
1418 "v root2",
1419 " > b",
1420 " four.txt",
1421 " one.txt",
1422 " three.txt <== selected <== marked",
1423 " two.txt",
1424 ]
1425 );
1426
1427 select_path(&panel, "root1/a", cx);
1428 panel.update_in(cx, |panel, window, cx| {
1429 panel.cut(&Default::default(), window, cx);
1430 });
1431 select_path(&panel, "root2/two.txt", cx);
1432 panel.update_in(cx, |panel, window, cx| {
1433 panel.select_next(&Default::default(), window, cx);
1434 panel.paste(&Default::default(), window, cx);
1435 });
1436
1437 cx.executor().run_until_parked();
1438 assert_eq!(
1439 visible_entries_as_strings(&panel, 0..50, cx),
1440 &[
1441 //
1442 "v root1",
1443 " one.txt",
1444 " two.txt",
1445 "v root2",
1446 " > a <== selected",
1447 " > b",
1448 " four.txt",
1449 " one.txt",
1450 " three.txt <== marked",
1451 " two.txt",
1452 ]
1453 );
1454}
1455
1456#[gpui::test]
1457async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
1458 init_test(cx);
1459
1460 let fs = FakeFs::new(cx.executor());
1461 fs.insert_tree(
1462 "/root1",
1463 json!({
1464 "one.txt": "",
1465 "two.txt": "",
1466 "three.txt": "",
1467 "a": {
1468 "0": { "q": "", "r": "", "s": "" },
1469 "1": { "t": "", "u": "" },
1470 "2": { "v": "", "w": "", "x": "", "y": "" },
1471 },
1472 }),
1473 )
1474 .await;
1475
1476 fs.insert_tree(
1477 "/root2",
1478 json!({
1479 "one.txt": "",
1480 "two.txt": "",
1481 "four.txt": "",
1482 "b": {
1483 "3": { "Q": "" },
1484 "4": { "R": "", "S": "", "T": "", "U": "" },
1485 },
1486 }),
1487 )
1488 .await;
1489
1490 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1491 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1492 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1493 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1494 cx.run_until_parked();
1495
1496 select_path(&panel, "root1/three.txt", cx);
1497 panel.update_in(cx, |panel, window, cx| {
1498 panel.copy(&Default::default(), window, cx);
1499 });
1500
1501 select_path(&panel, "root2/one.txt", cx);
1502 panel.update_in(cx, |panel, window, cx| {
1503 panel.select_next(&Default::default(), window, cx);
1504 panel.paste(&Default::default(), window, cx);
1505 });
1506 cx.executor().run_until_parked();
1507 assert_eq!(
1508 visible_entries_as_strings(&panel, 0..50, cx),
1509 &[
1510 //
1511 "v root1",
1512 " > a",
1513 " one.txt",
1514 " three.txt",
1515 " two.txt",
1516 "v root2",
1517 " > b",
1518 " four.txt",
1519 " one.txt",
1520 " three.txt <== selected <== marked",
1521 " two.txt",
1522 ]
1523 );
1524
1525 select_path(&panel, "root1/three.txt", cx);
1526 panel.update_in(cx, |panel, window, cx| {
1527 panel.copy(&Default::default(), window, cx);
1528 });
1529 select_path(&panel, "root2/two.txt", cx);
1530 panel.update_in(cx, |panel, window, cx| {
1531 panel.select_next(&Default::default(), window, cx);
1532 panel.paste(&Default::default(), window, cx);
1533 });
1534
1535 cx.executor().run_until_parked();
1536 assert_eq!(
1537 visible_entries_as_strings(&panel, 0..50, cx),
1538 &[
1539 //
1540 "v root1",
1541 " > a",
1542 " one.txt",
1543 " three.txt",
1544 " two.txt",
1545 "v root2",
1546 " > b",
1547 " four.txt",
1548 " one.txt",
1549 " three.txt",
1550 " [EDITOR: 'three copy.txt'] <== selected <== marked",
1551 " two.txt",
1552 ]
1553 );
1554
1555 panel.update_in(cx, |panel, window, cx| {
1556 panel.cancel(&menu::Cancel {}, window, cx)
1557 });
1558 cx.executor().run_until_parked();
1559
1560 select_path(&panel, "root1/a", cx);
1561 panel.update_in(cx, |panel, window, cx| {
1562 panel.copy(&Default::default(), window, cx);
1563 });
1564 select_path(&panel, "root2/two.txt", cx);
1565 panel.update_in(cx, |panel, window, cx| {
1566 panel.select_next(&Default::default(), window, cx);
1567 panel.paste(&Default::default(), window, cx);
1568 });
1569
1570 cx.executor().run_until_parked();
1571 assert_eq!(
1572 visible_entries_as_strings(&panel, 0..50, cx),
1573 &[
1574 //
1575 "v root1",
1576 " > a",
1577 " one.txt",
1578 " three.txt",
1579 " two.txt",
1580 "v root2",
1581 " > a <== selected",
1582 " > b",
1583 " four.txt",
1584 " one.txt",
1585 " three.txt",
1586 " three copy.txt",
1587 " two.txt",
1588 ]
1589 );
1590}
1591
1592#[gpui::test]
1593async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
1594 init_test(cx);
1595
1596 let fs = FakeFs::new(cx.executor());
1597 fs.insert_tree(
1598 "/root",
1599 json!({
1600 "a": {
1601 "one.txt": "",
1602 "two.txt": "",
1603 "inner_dir": {
1604 "three.txt": "",
1605 "four.txt": "",
1606 }
1607 },
1608 "b": {}
1609 }),
1610 )
1611 .await;
1612
1613 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
1614 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1615 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1616 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1617 cx.run_until_parked();
1618
1619 select_path(&panel, "root/a", cx);
1620 panel.update_in(cx, |panel, window, cx| {
1621 panel.copy(&Default::default(), window, cx);
1622 panel.select_next(&Default::default(), window, cx);
1623 panel.paste(&Default::default(), window, cx);
1624 });
1625 cx.executor().run_until_parked();
1626
1627 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
1628 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
1629
1630 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
1631 assert_ne!(
1632 pasted_dir_file, None,
1633 "Pasted directory file should have an entry"
1634 );
1635
1636 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
1637 assert_ne!(
1638 pasted_dir_inner_dir, None,
1639 "Directories inside pasted directory should have an entry"
1640 );
1641
1642 toggle_expand_dir(&panel, "root/b/a", cx);
1643 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
1644
1645 assert_eq!(
1646 visible_entries_as_strings(&panel, 0..50, cx),
1647 &[
1648 //
1649 "v root",
1650 " > a",
1651 " v b",
1652 " v a",
1653 " v inner_dir <== selected",
1654 " four.txt",
1655 " three.txt",
1656 " one.txt",
1657 " two.txt",
1658 ]
1659 );
1660
1661 select_path(&panel, "root", cx);
1662 panel.update_in(cx, |panel, window, cx| {
1663 panel.paste(&Default::default(), window, cx)
1664 });
1665 cx.executor().run_until_parked();
1666 assert_eq!(
1667 visible_entries_as_strings(&panel, 0..50, cx),
1668 &[
1669 //
1670 "v root",
1671 " > a",
1672 " > [EDITOR: 'a copy'] <== selected",
1673 " v b",
1674 " v a",
1675 " v inner_dir",
1676 " four.txt",
1677 " three.txt",
1678 " one.txt",
1679 " two.txt"
1680 ]
1681 );
1682
1683 let confirm = panel.update_in(cx, |panel, window, cx| {
1684 panel
1685 .filename_editor
1686 .update(cx, |editor, cx| editor.set_text("c", window, cx));
1687 panel.confirm_edit(true, window, cx).unwrap()
1688 });
1689 assert_eq!(
1690 visible_entries_as_strings(&panel, 0..50, cx),
1691 &[
1692 //
1693 "v root",
1694 " > a",
1695 " > [PROCESSING: 'c'] <== selected",
1696 " v b",
1697 " v a",
1698 " v inner_dir",
1699 " four.txt",
1700 " three.txt",
1701 " one.txt",
1702 " two.txt"
1703 ]
1704 );
1705
1706 confirm.await.unwrap();
1707
1708 panel.update_in(cx, |panel, window, cx| {
1709 panel.paste(&Default::default(), window, cx)
1710 });
1711 cx.executor().run_until_parked();
1712 assert_eq!(
1713 visible_entries_as_strings(&panel, 0..50, cx),
1714 &[
1715 //
1716 "v root",
1717 " > a",
1718 " v b",
1719 " v a",
1720 " v inner_dir",
1721 " four.txt",
1722 " three.txt",
1723 " one.txt",
1724 " two.txt",
1725 " v c",
1726 " > a <== selected",
1727 " > inner_dir",
1728 " one.txt",
1729 " two.txt",
1730 ]
1731 );
1732}
1733
1734#[gpui::test]
1735async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
1736 init_test(cx);
1737
1738 let fs = FakeFs::new(cx.executor());
1739 fs.insert_tree(
1740 "/test",
1741 json!({
1742 "dir1": {
1743 "a.txt": "",
1744 "b.txt": "",
1745 },
1746 "dir2": {},
1747 "c.txt": "",
1748 "d.txt": "",
1749 }),
1750 )
1751 .await;
1752
1753 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1754 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1755 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1756 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1757 cx.run_until_parked();
1758
1759 toggle_expand_dir(&panel, "test/dir1", cx);
1760
1761 cx.simulate_modifiers_change(gpui::Modifiers {
1762 control: true,
1763 ..Default::default()
1764 });
1765
1766 select_path_with_mark(&panel, "test/dir1", cx);
1767 select_path_with_mark(&panel, "test/c.txt", cx);
1768
1769 assert_eq!(
1770 visible_entries_as_strings(&panel, 0..15, cx),
1771 &[
1772 "v test",
1773 " v dir1 <== marked",
1774 " a.txt",
1775 " b.txt",
1776 " > dir2",
1777 " c.txt <== selected <== marked",
1778 " d.txt",
1779 ],
1780 "Initial state before copying dir1 and c.txt"
1781 );
1782
1783 panel.update_in(cx, |panel, window, cx| {
1784 panel.copy(&Default::default(), window, cx);
1785 });
1786 select_path(&panel, "test/dir2", cx);
1787 panel.update_in(cx, |panel, window, cx| {
1788 panel.paste(&Default::default(), window, cx);
1789 });
1790 cx.executor().run_until_parked();
1791
1792 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1793
1794 assert_eq!(
1795 visible_entries_as_strings(&panel, 0..15, cx),
1796 &[
1797 "v test",
1798 " v dir1 <== marked",
1799 " a.txt",
1800 " b.txt",
1801 " v dir2",
1802 " v dir1 <== selected",
1803 " a.txt",
1804 " b.txt",
1805 " c.txt",
1806 " c.txt <== marked",
1807 " d.txt",
1808 ],
1809 "Should copy dir1 as well as c.txt into dir2"
1810 );
1811
1812 // Disambiguating multiple files should not open the rename editor.
1813 select_path(&panel, "test/dir2", cx);
1814 panel.update_in(cx, |panel, window, cx| {
1815 panel.paste(&Default::default(), window, cx);
1816 });
1817 cx.executor().run_until_parked();
1818
1819 assert_eq!(
1820 visible_entries_as_strings(&panel, 0..15, cx),
1821 &[
1822 "v test",
1823 " v dir1 <== marked",
1824 " a.txt",
1825 " b.txt",
1826 " v dir2",
1827 " v dir1",
1828 " a.txt",
1829 " b.txt",
1830 " > dir1 copy <== selected",
1831 " c.txt",
1832 " c copy.txt",
1833 " c.txt <== marked",
1834 " d.txt",
1835 ],
1836 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
1837 );
1838}
1839
1840#[gpui::test]
1841async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
1842 init_test(cx);
1843
1844 let fs = FakeFs::new(cx.executor());
1845 fs.insert_tree(
1846 "/test",
1847 json!({
1848 "dir1": {
1849 "a.txt": "",
1850 "b.txt": "",
1851 },
1852 "dir2": {},
1853 "c.txt": "",
1854 "d.txt": "",
1855 }),
1856 )
1857 .await;
1858
1859 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1860 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1861 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1862 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1863 cx.run_until_parked();
1864
1865 toggle_expand_dir(&panel, "test/dir1", cx);
1866
1867 cx.simulate_modifiers_change(gpui::Modifiers {
1868 control: true,
1869 ..Default::default()
1870 });
1871
1872 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
1873 select_path_with_mark(&panel, "test/dir1", cx);
1874 select_path_with_mark(&panel, "test/c.txt", cx);
1875
1876 assert_eq!(
1877 visible_entries_as_strings(&panel, 0..15, cx),
1878 &[
1879 "v test",
1880 " v dir1 <== marked",
1881 " a.txt <== marked",
1882 " b.txt",
1883 " > dir2",
1884 " c.txt <== selected <== marked",
1885 " d.txt",
1886 ],
1887 "Initial state before copying a.txt, dir1 and c.txt"
1888 );
1889
1890 panel.update_in(cx, |panel, window, cx| {
1891 panel.copy(&Default::default(), window, cx);
1892 });
1893 select_path(&panel, "test/dir2", cx);
1894 panel.update_in(cx, |panel, window, cx| {
1895 panel.paste(&Default::default(), window, cx);
1896 });
1897 cx.executor().run_until_parked();
1898
1899 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
1900
1901 assert_eq!(
1902 visible_entries_as_strings(&panel, 0..20, cx),
1903 &[
1904 "v test",
1905 " v dir1 <== marked",
1906 " a.txt <== marked",
1907 " b.txt",
1908 " v dir2",
1909 " v dir1 <== selected",
1910 " a.txt",
1911 " b.txt",
1912 " c.txt",
1913 " c.txt <== marked",
1914 " d.txt",
1915 ],
1916 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
1917 );
1918}
1919
1920#[gpui::test]
1921async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
1922 init_test_with_editor(cx);
1923
1924 let fs = FakeFs::new(cx.executor());
1925 fs.insert_tree(
1926 path!("/src"),
1927 json!({
1928 "test": {
1929 "first.rs": "// First Rust file",
1930 "second.rs": "// Second Rust file",
1931 "third.rs": "// Third Rust file",
1932 }
1933 }),
1934 )
1935 .await;
1936
1937 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
1938 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1939 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1940 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
1941 cx.run_until_parked();
1942
1943 toggle_expand_dir(&panel, "src/test", cx);
1944 select_path(&panel, "src/test/first.rs", cx);
1945 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1946 cx.executor().run_until_parked();
1947 assert_eq!(
1948 visible_entries_as_strings(&panel, 0..10, cx),
1949 &[
1950 "v src",
1951 " v test",
1952 " first.rs <== selected <== marked",
1953 " second.rs",
1954 " third.rs"
1955 ]
1956 );
1957 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
1958
1959 submit_deletion(&panel, cx);
1960 assert_eq!(
1961 visible_entries_as_strings(&panel, 0..10, cx),
1962 &[
1963 "v src",
1964 " v test",
1965 " second.rs <== selected",
1966 " third.rs"
1967 ],
1968 "Project panel should have no deleted file, no other file is selected in it"
1969 );
1970 ensure_no_open_items_and_panes(&workspace, cx);
1971
1972 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
1973 cx.executor().run_until_parked();
1974 assert_eq!(
1975 visible_entries_as_strings(&panel, 0..10, cx),
1976 &[
1977 "v src",
1978 " v test",
1979 " second.rs <== selected <== marked",
1980 " third.rs"
1981 ]
1982 );
1983 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
1984
1985 workspace
1986 .update(cx, |workspace, window, cx| {
1987 let active_items = workspace
1988 .panes()
1989 .iter()
1990 .filter_map(|pane| pane.read(cx).active_item())
1991 .collect::<Vec<_>>();
1992 assert_eq!(active_items.len(), 1);
1993 let open_editor = active_items
1994 .into_iter()
1995 .next()
1996 .unwrap()
1997 .downcast::<Editor>()
1998 .expect("Open item should be an editor");
1999 open_editor.update(cx, |editor, cx| {
2000 editor.set_text("Another text!", window, cx)
2001 });
2002 })
2003 .unwrap();
2004 submit_deletion_skipping_prompt(&panel, cx);
2005 assert_eq!(
2006 visible_entries_as_strings(&panel, 0..10, cx),
2007 &["v src", " v test", " third.rs <== selected"],
2008 "Project panel should have no deleted file, with one last file remaining"
2009 );
2010 ensure_no_open_items_and_panes(&workspace, cx);
2011}
2012
2013#[gpui::test]
2014async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) {
2015 init_test_with_editor(cx);
2016 set_auto_open_settings(
2017 cx,
2018 ProjectPanelAutoOpenSettings {
2019 on_create: Some(true),
2020 ..Default::default()
2021 },
2022 );
2023
2024 let fs = FakeFs::new(cx.executor());
2025 fs.insert_tree(path!("/root"), json!({})).await;
2026
2027 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2028 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2029 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2030 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2031 cx.run_until_parked();
2032
2033 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2034 cx.run_until_parked();
2035 panel
2036 .update_in(cx, |panel, window, cx| {
2037 panel.filename_editor.update(cx, |editor, cx| {
2038 editor.set_text("auto-open.rs", window, cx);
2039 });
2040 panel.confirm_edit(true, window, cx).unwrap()
2041 })
2042 .await
2043 .unwrap();
2044 cx.run_until_parked();
2045
2046 ensure_single_file_is_opened(&workspace, "auto-open.rs", cx);
2047}
2048
2049#[gpui::test]
2050async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) {
2051 init_test_with_editor(cx);
2052 set_auto_open_settings(
2053 cx,
2054 ProjectPanelAutoOpenSettings {
2055 on_create: Some(false),
2056 ..Default::default()
2057 },
2058 );
2059
2060 let fs = FakeFs::new(cx.executor());
2061 fs.insert_tree(path!("/root"), json!({})).await;
2062
2063 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2064 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2065 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2066 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2067 cx.run_until_parked();
2068
2069 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2070 cx.run_until_parked();
2071 panel
2072 .update_in(cx, |panel, window, cx| {
2073 panel.filename_editor.update(cx, |editor, cx| {
2074 editor.set_text("manual-open.rs", window, cx);
2075 });
2076 panel.confirm_edit(true, window, cx).unwrap()
2077 })
2078 .await
2079 .unwrap();
2080 cx.run_until_parked();
2081
2082 ensure_no_open_items_and_panes(&workspace, cx);
2083}
2084
2085#[gpui::test]
2086async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) {
2087 init_test_with_editor(cx);
2088 set_auto_open_settings(
2089 cx,
2090 ProjectPanelAutoOpenSettings {
2091 on_paste: Some(true),
2092 ..Default::default()
2093 },
2094 );
2095
2096 let fs = FakeFs::new(cx.executor());
2097 fs.insert_tree(
2098 path!("/root"),
2099 json!({
2100 "src": {
2101 "original.rs": ""
2102 },
2103 "target": {}
2104 }),
2105 )
2106 .await;
2107
2108 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2109 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2110 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2111 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2112 cx.run_until_parked();
2113
2114 toggle_expand_dir(&panel, "root/src", cx);
2115 toggle_expand_dir(&panel, "root/target", cx);
2116
2117 select_path(&panel, "root/src/original.rs", cx);
2118 panel.update_in(cx, |panel, window, cx| {
2119 panel.copy(&Default::default(), window, cx);
2120 });
2121
2122 select_path(&panel, "root/target", cx);
2123 panel.update_in(cx, |panel, window, cx| {
2124 panel.paste(&Default::default(), window, cx);
2125 });
2126 cx.executor().run_until_parked();
2127
2128 ensure_single_file_is_opened(&workspace, "target/original.rs", cx);
2129}
2130
2131#[gpui::test]
2132async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) {
2133 init_test_with_editor(cx);
2134 set_auto_open_settings(
2135 cx,
2136 ProjectPanelAutoOpenSettings {
2137 on_paste: Some(false),
2138 ..Default::default()
2139 },
2140 );
2141
2142 let fs = FakeFs::new(cx.executor());
2143 fs.insert_tree(
2144 path!("/root"),
2145 json!({
2146 "src": {
2147 "original.rs": ""
2148 },
2149 "target": {}
2150 }),
2151 )
2152 .await;
2153
2154 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2155 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2156 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2157 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2158 cx.run_until_parked();
2159
2160 toggle_expand_dir(&panel, "root/src", cx);
2161 toggle_expand_dir(&panel, "root/target", cx);
2162
2163 select_path(&panel, "root/src/original.rs", cx);
2164 panel.update_in(cx, |panel, window, cx| {
2165 panel.copy(&Default::default(), window, cx);
2166 });
2167
2168 select_path(&panel, "root/target", cx);
2169 panel.update_in(cx, |panel, window, cx| {
2170 panel.paste(&Default::default(), window, cx);
2171 });
2172 cx.executor().run_until_parked();
2173
2174 ensure_no_open_items_and_panes(&workspace, cx);
2175 assert!(
2176 find_project_entry(&panel, "root/target/original.rs", cx).is_some(),
2177 "Pasted entry should exist even when auto-open is disabled"
2178 );
2179}
2180
2181#[gpui::test]
2182async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) {
2183 init_test_with_editor(cx);
2184 set_auto_open_settings(
2185 cx,
2186 ProjectPanelAutoOpenSettings {
2187 on_drop: Some(true),
2188 ..Default::default()
2189 },
2190 );
2191
2192 let fs = FakeFs::new(cx.executor());
2193 fs.insert_tree(path!("/root"), json!({})).await;
2194
2195 let temp_dir = tempfile::tempdir().unwrap();
2196 let external_path = temp_dir.path().join("dropped.rs");
2197 std::fs::write(&external_path, "// dropped").unwrap();
2198 fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2199 .await;
2200
2201 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2202 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2203 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2204 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2205 cx.run_until_parked();
2206
2207 let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2208 panel.update_in(cx, |panel, window, cx| {
2209 panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2210 });
2211 cx.executor().run_until_parked();
2212
2213 ensure_single_file_is_opened(&workspace, "dropped.rs", cx);
2214}
2215
2216#[gpui::test]
2217async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) {
2218 init_test_with_editor(cx);
2219 set_auto_open_settings(
2220 cx,
2221 ProjectPanelAutoOpenSettings {
2222 on_drop: Some(false),
2223 ..Default::default()
2224 },
2225 );
2226
2227 let fs = FakeFs::new(cx.executor());
2228 fs.insert_tree(path!("/root"), json!({})).await;
2229
2230 let temp_dir = tempfile::tempdir().unwrap();
2231 let external_path = temp_dir.path().join("manual.rs");
2232 std::fs::write(&external_path, "// dropped").unwrap();
2233 fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2234 .await;
2235
2236 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2237 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2238 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2239 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2240 cx.run_until_parked();
2241
2242 let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2243 panel.update_in(cx, |panel, window, cx| {
2244 panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2245 });
2246 cx.executor().run_until_parked();
2247
2248 ensure_no_open_items_and_panes(&workspace, cx);
2249 assert!(
2250 find_project_entry(&panel, "root/manual.rs", cx).is_some(),
2251 "Dropped entry should exist even when auto-open is disabled"
2252 );
2253}
2254
2255#[gpui::test]
2256async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2257 init_test_with_editor(cx);
2258
2259 let fs = FakeFs::new(cx.executor());
2260 fs.insert_tree(
2261 "/src",
2262 json!({
2263 "test": {
2264 "first.rs": "// First Rust file",
2265 "second.rs": "// Second Rust file",
2266 "third.rs": "// Third Rust file",
2267 }
2268 }),
2269 )
2270 .await;
2271
2272 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2273 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2274 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2275 let panel = workspace
2276 .update(cx, |workspace, window, cx| {
2277 let panel = ProjectPanel::new(workspace, window, cx);
2278 workspace.add_panel(panel.clone(), window, cx);
2279 panel
2280 })
2281 .unwrap();
2282 cx.run_until_parked();
2283
2284 select_path(&panel, "src", cx);
2285 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2286 cx.executor().run_until_parked();
2287 assert_eq!(
2288 visible_entries_as_strings(&panel, 0..10, cx),
2289 &[
2290 //
2291 "v src <== selected",
2292 " > test"
2293 ]
2294 );
2295 panel.update_in(cx, |panel, window, cx| {
2296 panel.new_directory(&NewDirectory, window, cx)
2297 });
2298 cx.run_until_parked();
2299 panel.update_in(cx, |panel, window, cx| {
2300 assert!(panel.filename_editor.read(cx).is_focused(window));
2301 });
2302 cx.executor().run_until_parked();
2303 assert_eq!(
2304 visible_entries_as_strings(&panel, 0..10, cx),
2305 &[
2306 //
2307 "v src",
2308 " > [EDITOR: ''] <== selected",
2309 " > test"
2310 ]
2311 );
2312 panel.update_in(cx, |panel, window, cx| {
2313 panel
2314 .filename_editor
2315 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2316 assert!(
2317 panel.confirm_edit(true, window, cx).is_none(),
2318 "Should not allow to confirm on conflicting new directory name"
2319 );
2320 });
2321 cx.executor().run_until_parked();
2322 panel.update_in(cx, |panel, window, cx| {
2323 assert!(
2324 panel.state.edit_state.is_some(),
2325 "Edit state should not be None after conflicting new directory name"
2326 );
2327 panel.cancel(&menu::Cancel, window, cx);
2328 });
2329 cx.run_until_parked();
2330 assert_eq!(
2331 visible_entries_as_strings(&panel, 0..10, cx),
2332 &[
2333 //
2334 "v src <== selected",
2335 " > test"
2336 ],
2337 "File list should be unchanged after failed folder create confirmation"
2338 );
2339
2340 select_path(&panel, "src/test", cx);
2341 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2342 cx.executor().run_until_parked();
2343 assert_eq!(
2344 visible_entries_as_strings(&panel, 0..10, cx),
2345 &[
2346 //
2347 "v src",
2348 " > test <== selected"
2349 ]
2350 );
2351 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2352 cx.run_until_parked();
2353 panel.update_in(cx, |panel, window, cx| {
2354 assert!(panel.filename_editor.read(cx).is_focused(window));
2355 });
2356 assert_eq!(
2357 visible_entries_as_strings(&panel, 0..10, cx),
2358 &[
2359 "v src",
2360 " v test",
2361 " [EDITOR: ''] <== selected",
2362 " first.rs",
2363 " second.rs",
2364 " third.rs"
2365 ]
2366 );
2367 panel.update_in(cx, |panel, window, cx| {
2368 panel
2369 .filename_editor
2370 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2371 assert!(
2372 panel.confirm_edit(true, window, cx).is_none(),
2373 "Should not allow to confirm on conflicting new file name"
2374 );
2375 });
2376 cx.executor().run_until_parked();
2377 panel.update_in(cx, |panel, window, cx| {
2378 assert!(
2379 panel.state.edit_state.is_some(),
2380 "Edit state should not be None after conflicting new file name"
2381 );
2382 panel.cancel(&menu::Cancel, window, cx);
2383 });
2384 cx.run_until_parked();
2385 assert_eq!(
2386 visible_entries_as_strings(&panel, 0..10, cx),
2387 &[
2388 "v src",
2389 " v test <== selected",
2390 " first.rs",
2391 " second.rs",
2392 " third.rs"
2393 ],
2394 "File list should be unchanged after failed file create confirmation"
2395 );
2396
2397 select_path(&panel, "src/test/first.rs", cx);
2398 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2399 cx.executor().run_until_parked();
2400 assert_eq!(
2401 visible_entries_as_strings(&panel, 0..10, cx),
2402 &[
2403 "v src",
2404 " v test",
2405 " first.rs <== selected",
2406 " second.rs",
2407 " third.rs"
2408 ],
2409 );
2410 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2411 cx.executor().run_until_parked();
2412 panel.update_in(cx, |panel, window, cx| {
2413 assert!(panel.filename_editor.read(cx).is_focused(window));
2414 });
2415 assert_eq!(
2416 visible_entries_as_strings(&panel, 0..10, cx),
2417 &[
2418 "v src",
2419 " v test",
2420 " [EDITOR: 'first.rs'] <== selected",
2421 " second.rs",
2422 " third.rs"
2423 ]
2424 );
2425 panel.update_in(cx, |panel, window, cx| {
2426 panel
2427 .filename_editor
2428 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2429 assert!(
2430 panel.confirm_edit(true, window, cx).is_none(),
2431 "Should not allow to confirm on conflicting file rename"
2432 )
2433 });
2434 cx.executor().run_until_parked();
2435 panel.update_in(cx, |panel, window, cx| {
2436 assert!(
2437 panel.state.edit_state.is_some(),
2438 "Edit state should not be None after conflicting file rename"
2439 );
2440 panel.cancel(&menu::Cancel, window, cx);
2441 });
2442 cx.executor().run_until_parked();
2443 assert_eq!(
2444 visible_entries_as_strings(&panel, 0..10, cx),
2445 &[
2446 "v src",
2447 " v test",
2448 " first.rs <== selected",
2449 " second.rs",
2450 " third.rs"
2451 ],
2452 "File list should be unchanged after failed rename confirmation"
2453 );
2454}
2455
2456// NOTE: This test is skipped on Windows, because on Windows,
2457// when it triggers the lsp store it converts `/src/test/first copy.txt` into an uri
2458// but it fails with message `"/src\\test\\first copy.txt" is not parseable as an URI`
2459#[gpui::test]
2460#[cfg_attr(target_os = "windows", ignore)]
2461async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppContext) {
2462 init_test_with_editor(cx);
2463
2464 let fs = FakeFs::new(cx.executor());
2465 fs.insert_tree(
2466 "/src",
2467 json!({
2468 "test": {
2469 "first.txt": "// First Txt file",
2470 "second.txt": "// Second Txt file",
2471 "third.txt": "// Third Txt file",
2472 }
2473 }),
2474 )
2475 .await;
2476
2477 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2478 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2479 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2480 let panel = workspace
2481 .update(cx, |workspace, window, cx| {
2482 let panel = ProjectPanel::new(workspace, window, cx);
2483 workspace.add_panel(panel.clone(), window, cx);
2484 panel
2485 })
2486 .unwrap();
2487 cx.run_until_parked();
2488
2489 select_path(&panel, "src", cx);
2490 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2491 cx.executor().run_until_parked();
2492 assert_eq!(
2493 visible_entries_as_strings(&panel, 0..10, cx),
2494 &[
2495 //
2496 "v src <== selected",
2497 " > test"
2498 ]
2499 );
2500 panel.update_in(cx, |panel, window, cx| {
2501 panel.new_directory(&NewDirectory, window, cx)
2502 });
2503 cx.run_until_parked();
2504 panel.update_in(cx, |panel, window, cx| {
2505 assert!(panel.filename_editor.read(cx).is_focused(window));
2506 });
2507 cx.executor().run_until_parked();
2508 assert_eq!(
2509 visible_entries_as_strings(&panel, 0..10, cx),
2510 &[
2511 //
2512 "v src",
2513 " > [EDITOR: ''] <== selected",
2514 " > test"
2515 ]
2516 );
2517 panel.update_in(cx, |panel, window, cx| {
2518 panel
2519 .filename_editor
2520 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2521 assert!(
2522 panel.confirm_edit(true, window, cx).is_none(),
2523 "Should not allow to confirm on conflicting new directory name"
2524 );
2525 });
2526 cx.executor().run_until_parked();
2527 panel.update_in(cx, |panel, window, cx| {
2528 assert!(
2529 panel.state.edit_state.is_some(),
2530 "Edit state should not be None after conflicting new directory name"
2531 );
2532 panel.cancel(&menu::Cancel, window, cx);
2533 });
2534 cx.run_until_parked();
2535 assert_eq!(
2536 visible_entries_as_strings(&panel, 0..10, cx),
2537 &[
2538 //
2539 "v src <== selected",
2540 " > test"
2541 ],
2542 "File list should be unchanged after failed folder create confirmation"
2543 );
2544
2545 select_path(&panel, "src/test", cx);
2546 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2547 cx.executor().run_until_parked();
2548 assert_eq!(
2549 visible_entries_as_strings(&panel, 0..10, cx),
2550 &[
2551 //
2552 "v src",
2553 " > test <== selected"
2554 ]
2555 );
2556 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2557 cx.run_until_parked();
2558 panel.update_in(cx, |panel, window, cx| {
2559 assert!(panel.filename_editor.read(cx).is_focused(window));
2560 });
2561 assert_eq!(
2562 visible_entries_as_strings(&panel, 0..10, cx),
2563 &[
2564 "v src",
2565 " v test",
2566 " [EDITOR: ''] <== selected",
2567 " first.txt",
2568 " second.txt",
2569 " third.txt"
2570 ]
2571 );
2572 panel.update_in(cx, |panel, window, cx| {
2573 panel
2574 .filename_editor
2575 .update(cx, |editor, cx| editor.set_text("first.txt", window, cx));
2576 assert!(
2577 panel.confirm_edit(true, window, cx).is_none(),
2578 "Should not allow to confirm on conflicting new file name"
2579 );
2580 });
2581 cx.executor().run_until_parked();
2582 panel.update_in(cx, |panel, window, cx| {
2583 assert!(
2584 panel.state.edit_state.is_some(),
2585 "Edit state should not be None after conflicting new file name"
2586 );
2587 panel.cancel(&menu::Cancel, window, cx);
2588 });
2589 cx.run_until_parked();
2590 assert_eq!(
2591 visible_entries_as_strings(&panel, 0..10, cx),
2592 &[
2593 "v src",
2594 " v test <== selected",
2595 " first.txt",
2596 " second.txt",
2597 " third.txt"
2598 ],
2599 "File list should be unchanged after failed file create confirmation"
2600 );
2601
2602 select_path(&panel, "src/test/first.txt", cx);
2603 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2604 cx.executor().run_until_parked();
2605 assert_eq!(
2606 visible_entries_as_strings(&panel, 0..10, cx),
2607 &[
2608 "v src",
2609 " v test",
2610 " first.txt <== selected",
2611 " second.txt",
2612 " third.txt"
2613 ],
2614 );
2615 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2616 cx.executor().run_until_parked();
2617 panel.update_in(cx, |panel, window, cx| {
2618 assert!(panel.filename_editor.read(cx).is_focused(window));
2619 });
2620 assert_eq!(
2621 visible_entries_as_strings(&panel, 0..10, cx),
2622 &[
2623 "v src",
2624 " v test",
2625 " [EDITOR: 'first.txt'] <== selected",
2626 " second.txt",
2627 " third.txt"
2628 ]
2629 );
2630 panel.update_in(cx, |panel, window, cx| {
2631 panel
2632 .filename_editor
2633 .update(cx, |editor, cx| editor.set_text("second.txt", window, cx));
2634 assert!(
2635 panel.confirm_edit(true, window, cx).is_none(),
2636 "Should not allow to confirm on conflicting file rename"
2637 )
2638 });
2639 cx.executor().run_until_parked();
2640 panel.update_in(cx, |panel, window, cx| {
2641 assert!(
2642 panel.state.edit_state.is_some(),
2643 "Edit state should not be None after conflicting file rename"
2644 );
2645 panel.cancel(&menu::Cancel, window, cx);
2646 });
2647 cx.executor().run_until_parked();
2648 assert_eq!(
2649 visible_entries_as_strings(&panel, 0..10, cx),
2650 &[
2651 "v src",
2652 " v test",
2653 " first.txt <== selected",
2654 " second.txt",
2655 " third.txt"
2656 ],
2657 "File list should be unchanged after failed rename confirmation"
2658 );
2659 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2660 cx.executor().run_until_parked();
2661 // Try to duplicate and check history
2662 panel.update_in(cx, |panel, window, cx| {
2663 panel.duplicate(&Duplicate, window, cx)
2664 });
2665 cx.executor().run_until_parked();
2666
2667 assert_eq!(
2668 visible_entries_as_strings(&panel, 0..10, cx),
2669 &[
2670 "v src",
2671 " v test",
2672 " first.txt",
2673 " [EDITOR: 'first copy.txt'] <== selected <== marked",
2674 " second.txt",
2675 " third.txt"
2676 ],
2677 );
2678
2679 let confirm = panel.update_in(cx, |panel, window, cx| {
2680 panel
2681 .filename_editor
2682 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2683 panel.confirm_edit(true, window, cx).unwrap()
2684 });
2685 confirm.await.unwrap();
2686 cx.executor().run_until_parked();
2687
2688 assert_eq!(
2689 visible_entries_as_strings(&panel, 0..10, cx),
2690 &[
2691 "v src",
2692 " v test",
2693 " first.txt",
2694 " fourth.txt <== selected",
2695 " second.txt",
2696 " third.txt"
2697 ],
2698 "File list should be different after rename confirmation"
2699 );
2700
2701 panel.update_in(cx, |panel, window, cx| {
2702 panel.update_visible_entries(None, false, false, window, cx);
2703 });
2704 cx.executor().run_until_parked();
2705
2706 select_path(&panel, "src/test/first.txt", cx);
2707 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2708 cx.executor().run_until_parked();
2709
2710 workspace
2711 .read_with(cx, |this, cx| {
2712 assert!(
2713 this.recent_navigation_history_iter(cx)
2714 .any(|(project_path, abs_path)| {
2715 project_path.path == Arc::from(rel_path("test/fourth.txt"))
2716 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2717 })
2718 );
2719 })
2720 .unwrap();
2721}
2722
2723// NOTE: This test is skipped on Windows, because on Windows,
2724// when it triggers the lsp store it converts `/src/test/first.txt` into an uri
2725// but it fails with message `"/src\\test\\first.txt" is not parseable as an URI`
2726#[gpui::test]
2727#[cfg_attr(target_os = "windows", ignore)]
2728async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) {
2729 init_test_with_editor(cx);
2730
2731 let fs = FakeFs::new(cx.executor());
2732 fs.insert_tree(
2733 "/src",
2734 json!({
2735 "test": {
2736 "first.txt": "// First Txt file",
2737 "second.txt": "// Second Txt file",
2738 "third.txt": "// Third Txt file",
2739 }
2740 }),
2741 )
2742 .await;
2743
2744 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2745 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2746 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2747 let panel = workspace
2748 .update(cx, |workspace, window, cx| {
2749 let panel = ProjectPanel::new(workspace, window, cx);
2750 workspace.add_panel(panel.clone(), window, cx);
2751 panel
2752 })
2753 .unwrap();
2754 cx.run_until_parked();
2755
2756 select_path(&panel, "src", 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 <== selected",
2764 " > test"
2765 ]
2766 );
2767
2768 select_path(&panel, "src/test", cx);
2769 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2770 cx.executor().run_until_parked();
2771 assert_eq!(
2772 visible_entries_as_strings(&panel, 0..10, cx),
2773 &[
2774 //
2775 "v src",
2776 " > test <== selected"
2777 ]
2778 );
2779 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2780 cx.run_until_parked();
2781 panel.update_in(cx, |panel, window, cx| {
2782 assert!(panel.filename_editor.read(cx).is_focused(window));
2783 });
2784
2785 select_path(&panel, "src/test/first.txt", cx);
2786 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2787 cx.executor().run_until_parked();
2788
2789 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2790 cx.executor().run_until_parked();
2791
2792 assert_eq!(
2793 visible_entries_as_strings(&panel, 0..10, cx),
2794 &[
2795 "v src",
2796 " v test",
2797 " [EDITOR: 'first.txt'] <== selected <== marked",
2798 " second.txt",
2799 " third.txt"
2800 ],
2801 );
2802
2803 let confirm = panel.update_in(cx, |panel, window, cx| {
2804 panel
2805 .filename_editor
2806 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2807 panel.confirm_edit(true, window, cx).unwrap()
2808 });
2809 confirm.await.unwrap();
2810 cx.executor().run_until_parked();
2811
2812 assert_eq!(
2813 visible_entries_as_strings(&panel, 0..10, cx),
2814 &[
2815 "v src",
2816 " v test",
2817 " fourth.txt <== selected",
2818 " second.txt",
2819 " third.txt"
2820 ],
2821 "File list should be different after rename confirmation"
2822 );
2823
2824 panel.update_in(cx, |panel, window, cx| {
2825 panel.update_visible_entries(None, false, false, window, cx);
2826 });
2827 cx.executor().run_until_parked();
2828
2829 select_path(&panel, "src/test/second.txt", cx);
2830 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2831 cx.executor().run_until_parked();
2832
2833 workspace
2834 .read_with(cx, |this, cx| {
2835 assert!(
2836 this.recent_navigation_history_iter(cx)
2837 .any(|(project_path, abs_path)| {
2838 project_path.path == Arc::from(rel_path("test/fourth.txt"))
2839 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2840 })
2841 );
2842 })
2843 .unwrap();
2844}
2845
2846#[gpui::test]
2847async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
2848 init_test_with_editor(cx);
2849
2850 let fs = FakeFs::new(cx.executor());
2851 fs.insert_tree(
2852 path!("/root"),
2853 json!({
2854 "tree1": {
2855 ".git": {},
2856 "dir1": {
2857 "modified1.txt": "1",
2858 "unmodified1.txt": "1",
2859 "modified2.txt": "1",
2860 },
2861 "dir2": {
2862 "modified3.txt": "1",
2863 "unmodified2.txt": "1",
2864 },
2865 "modified4.txt": "1",
2866 "unmodified3.txt": "1",
2867 },
2868 "tree2": {
2869 ".git": {},
2870 "dir3": {
2871 "modified5.txt": "1",
2872 "unmodified4.txt": "1",
2873 },
2874 "modified6.txt": "1",
2875 "unmodified5.txt": "1",
2876 }
2877 }),
2878 )
2879 .await;
2880
2881 // Mark files as git modified
2882 fs.set_head_and_index_for_repo(
2883 path!("/root/tree1/.git").as_ref(),
2884 &[
2885 ("dir1/modified1.txt", "modified".into()),
2886 ("dir1/modified2.txt", "modified".into()),
2887 ("modified4.txt", "modified".into()),
2888 ("dir2/modified3.txt", "modified".into()),
2889 ],
2890 );
2891 fs.set_head_and_index_for_repo(
2892 path!("/root/tree2/.git").as_ref(),
2893 &[
2894 ("dir3/modified5.txt", "modified".into()),
2895 ("modified6.txt", "modified".into()),
2896 ],
2897 );
2898
2899 let project = Project::test(
2900 fs.clone(),
2901 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2902 cx,
2903 )
2904 .await;
2905
2906 let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
2907 let mut worktrees = project.worktrees(cx);
2908 let worktree1 = worktrees.next().unwrap();
2909 let worktree2 = worktrees.next().unwrap();
2910 (
2911 worktree1.read(cx).as_local().unwrap().scan_complete(),
2912 worktree2.read(cx).as_local().unwrap().scan_complete(),
2913 )
2914 });
2915 scan1_complete.await;
2916 scan2_complete.await;
2917 cx.run_until_parked();
2918
2919 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2920 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2921 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2922 cx.run_until_parked();
2923
2924 // Check initial state
2925 assert_eq!(
2926 visible_entries_as_strings(&panel, 0..15, cx),
2927 &[
2928 "v tree1",
2929 " > .git",
2930 " > dir1",
2931 " > dir2",
2932 " modified4.txt",
2933 " unmodified3.txt",
2934 "v tree2",
2935 " > .git",
2936 " > dir3",
2937 " modified6.txt",
2938 " unmodified5.txt"
2939 ],
2940 );
2941
2942 // Test selecting next modified entry
2943 panel.update_in(cx, |panel, window, cx| {
2944 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2945 });
2946 cx.run_until_parked();
2947
2948 assert_eq!(
2949 visible_entries_as_strings(&panel, 0..6, cx),
2950 &[
2951 "v tree1",
2952 " > .git",
2953 " v dir1",
2954 " modified1.txt <== selected",
2955 " modified2.txt",
2956 " unmodified1.txt",
2957 ],
2958 );
2959
2960 panel.update_in(cx, |panel, window, cx| {
2961 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2962 });
2963 cx.run_until_parked();
2964
2965 assert_eq!(
2966 visible_entries_as_strings(&panel, 0..6, cx),
2967 &[
2968 "v tree1",
2969 " > .git",
2970 " v dir1",
2971 " modified1.txt",
2972 " modified2.txt <== selected",
2973 " unmodified1.txt",
2974 ],
2975 );
2976
2977 panel.update_in(cx, |panel, window, cx| {
2978 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2979 });
2980 cx.run_until_parked();
2981
2982 assert_eq!(
2983 visible_entries_as_strings(&panel, 6..9, cx),
2984 &[
2985 " v dir2",
2986 " modified3.txt <== selected",
2987 " unmodified2.txt",
2988 ],
2989 );
2990
2991 panel.update_in(cx, |panel, window, cx| {
2992 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2993 });
2994 cx.run_until_parked();
2995
2996 assert_eq!(
2997 visible_entries_as_strings(&panel, 9..11, cx),
2998 &[" modified4.txt <== selected", " unmodified3.txt",],
2999 );
3000
3001 panel.update_in(cx, |panel, window, cx| {
3002 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3003 });
3004 cx.run_until_parked();
3005
3006 assert_eq!(
3007 visible_entries_as_strings(&panel, 13..16, cx),
3008 &[
3009 " v dir3",
3010 " modified5.txt <== selected",
3011 " unmodified4.txt",
3012 ],
3013 );
3014
3015 panel.update_in(cx, |panel, window, cx| {
3016 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3017 });
3018 cx.run_until_parked();
3019
3020 assert_eq!(
3021 visible_entries_as_strings(&panel, 16..18, cx),
3022 &[" modified6.txt <== selected", " unmodified5.txt",],
3023 );
3024
3025 // Wraps around to first modified file
3026 panel.update_in(cx, |panel, window, cx| {
3027 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3028 });
3029 cx.run_until_parked();
3030
3031 assert_eq!(
3032 visible_entries_as_strings(&panel, 0..18, cx),
3033 &[
3034 "v tree1",
3035 " > .git",
3036 " v dir1",
3037 " modified1.txt <== selected",
3038 " modified2.txt",
3039 " unmodified1.txt",
3040 " v dir2",
3041 " modified3.txt",
3042 " unmodified2.txt",
3043 " modified4.txt",
3044 " unmodified3.txt",
3045 "v tree2",
3046 " > .git",
3047 " v dir3",
3048 " modified5.txt",
3049 " unmodified4.txt",
3050 " modified6.txt",
3051 " unmodified5.txt",
3052 ],
3053 );
3054
3055 // Wraps around again to last modified file
3056 panel.update_in(cx, |panel, window, cx| {
3057 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3058 });
3059 cx.run_until_parked();
3060
3061 assert_eq!(
3062 visible_entries_as_strings(&panel, 16..18, cx),
3063 &[" modified6.txt <== selected", " unmodified5.txt",],
3064 );
3065
3066 panel.update_in(cx, |panel, window, cx| {
3067 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3068 });
3069 cx.run_until_parked();
3070
3071 assert_eq!(
3072 visible_entries_as_strings(&panel, 13..16, cx),
3073 &[
3074 " v dir3",
3075 " modified5.txt <== selected",
3076 " unmodified4.txt",
3077 ],
3078 );
3079
3080 panel.update_in(cx, |panel, window, cx| {
3081 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3082 });
3083 cx.run_until_parked();
3084
3085 assert_eq!(
3086 visible_entries_as_strings(&panel, 9..11, cx),
3087 &[" modified4.txt <== selected", " unmodified3.txt",],
3088 );
3089
3090 panel.update_in(cx, |panel, window, cx| {
3091 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3092 });
3093 cx.run_until_parked();
3094
3095 assert_eq!(
3096 visible_entries_as_strings(&panel, 6..9, cx),
3097 &[
3098 " v dir2",
3099 " modified3.txt <== selected",
3100 " unmodified2.txt",
3101 ],
3102 );
3103
3104 panel.update_in(cx, |panel, window, cx| {
3105 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3106 });
3107 cx.run_until_parked();
3108
3109 assert_eq!(
3110 visible_entries_as_strings(&panel, 0..6, cx),
3111 &[
3112 "v tree1",
3113 " > .git",
3114 " v dir1",
3115 " modified1.txt",
3116 " modified2.txt <== selected",
3117 " unmodified1.txt",
3118 ],
3119 );
3120
3121 panel.update_in(cx, |panel, window, cx| {
3122 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3123 });
3124 cx.run_until_parked();
3125
3126 assert_eq!(
3127 visible_entries_as_strings(&panel, 0..6, cx),
3128 &[
3129 "v tree1",
3130 " > .git",
3131 " v dir1",
3132 " modified1.txt <== selected",
3133 " modified2.txt",
3134 " unmodified1.txt",
3135 ],
3136 );
3137}
3138
3139#[gpui::test]
3140async fn test_select_directory(cx: &mut gpui::TestAppContext) {
3141 init_test_with_editor(cx);
3142
3143 let fs = FakeFs::new(cx.executor());
3144 fs.insert_tree(
3145 "/project_root",
3146 json!({
3147 "dir_1": {
3148 "nested_dir": {
3149 "file_a.py": "# File contents",
3150 }
3151 },
3152 "file_1.py": "# File contents",
3153 "dir_2": {
3154
3155 },
3156 "dir_3": {
3157
3158 },
3159 "file_2.py": "# File contents",
3160 "dir_4": {
3161
3162 },
3163 }),
3164 )
3165 .await;
3166
3167 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3168 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3169 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3170 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3171 cx.run_until_parked();
3172
3173 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3174 cx.executor().run_until_parked();
3175 select_path(&panel, "project_root/dir_1", cx);
3176 cx.executor().run_until_parked();
3177 assert_eq!(
3178 visible_entries_as_strings(&panel, 0..10, cx),
3179 &[
3180 "v project_root",
3181 " > dir_1 <== selected",
3182 " > dir_2",
3183 " > dir_3",
3184 " > dir_4",
3185 " file_1.py",
3186 " file_2.py",
3187 ]
3188 );
3189 panel.update_in(cx, |panel, window, cx| {
3190 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3191 });
3192
3193 assert_eq!(
3194 visible_entries_as_strings(&panel, 0..10, cx),
3195 &[
3196 "v project_root <== selected",
3197 " > dir_1",
3198 " > dir_2",
3199 " > dir_3",
3200 " > dir_4",
3201 " file_1.py",
3202 " file_2.py",
3203 ]
3204 );
3205
3206 panel.update_in(cx, |panel, window, cx| {
3207 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3208 });
3209
3210 assert_eq!(
3211 visible_entries_as_strings(&panel, 0..10, cx),
3212 &[
3213 "v project_root",
3214 " > dir_1",
3215 " > dir_2",
3216 " > dir_3",
3217 " > dir_4 <== selected",
3218 " file_1.py",
3219 " file_2.py",
3220 ]
3221 );
3222
3223 panel.update_in(cx, |panel, window, cx| {
3224 panel.select_next_directory(&SelectNextDirectory, window, cx)
3225 });
3226
3227 assert_eq!(
3228 visible_entries_as_strings(&panel, 0..10, cx),
3229 &[
3230 "v project_root <== selected",
3231 " > dir_1",
3232 " > dir_2",
3233 " > dir_3",
3234 " > dir_4",
3235 " file_1.py",
3236 " file_2.py",
3237 ]
3238 );
3239}
3240
3241#[gpui::test]
3242async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
3243 init_test_with_editor(cx);
3244
3245 let fs = FakeFs::new(cx.executor());
3246 fs.insert_tree(
3247 "/project_root",
3248 json!({
3249 "dir_1": {
3250 "nested_dir": {
3251 "file_a.py": "# File contents",
3252 }
3253 },
3254 "file_1.py": "# File contents",
3255 "file_2.py": "# File contents",
3256 "zdir_2": {
3257 "nested_dir2": {
3258 "file_b.py": "# File contents",
3259 }
3260 },
3261 }),
3262 )
3263 .await;
3264
3265 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3266 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3267 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3268 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3269 cx.run_until_parked();
3270
3271 assert_eq!(
3272 visible_entries_as_strings(&panel, 0..10, cx),
3273 &[
3274 "v project_root",
3275 " > dir_1",
3276 " > zdir_2",
3277 " file_1.py",
3278 " file_2.py",
3279 ]
3280 );
3281 panel.update_in(cx, |panel, window, cx| {
3282 panel.select_first(&SelectFirst, window, cx)
3283 });
3284
3285 assert_eq!(
3286 visible_entries_as_strings(&panel, 0..10, cx),
3287 &[
3288 "v project_root <== selected",
3289 " > dir_1",
3290 " > zdir_2",
3291 " file_1.py",
3292 " file_2.py",
3293 ]
3294 );
3295
3296 panel.update_in(cx, |panel, window, cx| {
3297 panel.select_last(&SelectLast, window, cx)
3298 });
3299
3300 assert_eq!(
3301 visible_entries_as_strings(&panel, 0..10, cx),
3302 &[
3303 "v project_root",
3304 " > dir_1",
3305 " > zdir_2",
3306 " file_1.py",
3307 " file_2.py <== selected",
3308 ]
3309 );
3310
3311 cx.update(|_, cx| {
3312 let settings = *ProjectPanelSettings::get_global(cx);
3313 ProjectPanelSettings::override_global(
3314 ProjectPanelSettings {
3315 hide_root: true,
3316 ..settings
3317 },
3318 cx,
3319 );
3320 });
3321
3322 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3323 cx.run_until_parked();
3324
3325 #[rustfmt::skip]
3326 assert_eq!(
3327 visible_entries_as_strings(&panel, 0..10, cx),
3328 &[
3329 "> dir_1",
3330 "> zdir_2",
3331 " file_1.py",
3332 " file_2.py",
3333 ],
3334 "With hide_root=true, root should be hidden"
3335 );
3336
3337 panel.update_in(cx, |panel, window, cx| {
3338 panel.select_first(&SelectFirst, window, cx)
3339 });
3340
3341 assert_eq!(
3342 visible_entries_as_strings(&panel, 0..10, cx),
3343 &[
3344 "> dir_1 <== selected",
3345 "> zdir_2",
3346 " file_1.py",
3347 " file_2.py",
3348 ],
3349 "With hide_root=true, first entry should be dir_1, not the hidden root"
3350 );
3351}
3352
3353#[gpui::test]
3354async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3355 init_test_with_editor(cx);
3356
3357 let fs = FakeFs::new(cx.executor());
3358 fs.insert_tree(
3359 "/project_root",
3360 json!({
3361 "dir_1": {
3362 "nested_dir": {
3363 "file_a.py": "# File contents",
3364 }
3365 },
3366 "file_1.py": "# File contents",
3367 }),
3368 )
3369 .await;
3370
3371 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3372 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3373 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3374 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3375 cx.run_until_parked();
3376
3377 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3378 cx.executor().run_until_parked();
3379 select_path(&panel, "project_root/dir_1", cx);
3380 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3381 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3382 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3383 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3384 cx.executor().run_until_parked();
3385 assert_eq!(
3386 visible_entries_as_strings(&panel, 0..10, cx),
3387 &[
3388 "v project_root",
3389 " v dir_1",
3390 " > nested_dir <== selected",
3391 " file_1.py",
3392 ]
3393 );
3394}
3395
3396#[gpui::test]
3397async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3398 init_test_with_editor(cx);
3399
3400 let fs = FakeFs::new(cx.executor());
3401 fs.insert_tree(
3402 "/project_root",
3403 json!({
3404 "dir_1": {
3405 "nested_dir": {
3406 "file_a.py": "# File contents",
3407 "file_b.py": "# File contents",
3408 "file_c.py": "# File contents",
3409 },
3410 "file_1.py": "# File contents",
3411 "file_2.py": "# File contents",
3412 "file_3.py": "# File contents",
3413 },
3414 "dir_2": {
3415 "file_1.py": "# File contents",
3416 "file_2.py": "# File contents",
3417 "file_3.py": "# File contents",
3418 }
3419 }),
3420 )
3421 .await;
3422
3423 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3424 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3425 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3426 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3427 cx.run_until_parked();
3428
3429 panel.update_in(cx, |panel, window, cx| {
3430 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3431 });
3432 cx.executor().run_until_parked();
3433 assert_eq!(
3434 visible_entries_as_strings(&panel, 0..10, cx),
3435 &["v project_root", " > dir_1", " > dir_2",]
3436 );
3437
3438 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3439 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3440 cx.executor().run_until_parked();
3441 assert_eq!(
3442 visible_entries_as_strings(&panel, 0..10, cx),
3443 &[
3444 "v project_root",
3445 " v dir_1 <== selected",
3446 " > nested_dir",
3447 " file_1.py",
3448 " file_2.py",
3449 " file_3.py",
3450 " > dir_2",
3451 ]
3452 );
3453}
3454
3455#[gpui::test]
3456async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
3457 init_test_with_editor(cx);
3458
3459 let fs = FakeFs::new(cx.executor());
3460 let worktree_content = json!({
3461 "dir_1": {
3462 "file_1.py": "# File contents",
3463 },
3464 "dir_2": {
3465 "file_1.py": "# File contents",
3466 }
3467 });
3468
3469 fs.insert_tree("/project_root_1", worktree_content.clone())
3470 .await;
3471 fs.insert_tree("/project_root_2", worktree_content).await;
3472
3473 let project = Project::test(
3474 fs.clone(),
3475 ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
3476 cx,
3477 )
3478 .await;
3479 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3480 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3481 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3482 cx.run_until_parked();
3483
3484 panel.update_in(cx, |panel, window, cx| {
3485 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3486 });
3487 cx.executor().run_until_parked();
3488 assert_eq!(
3489 visible_entries_as_strings(&panel, 0..10, cx),
3490 &["> project_root_1", "> project_root_2",]
3491 );
3492}
3493
3494#[gpui::test]
3495async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
3496 init_test_with_editor(cx);
3497
3498 let fs = FakeFs::new(cx.executor());
3499 fs.insert_tree(
3500 "/project_root",
3501 json!({
3502 "dir_1": {
3503 "nested_dir": {
3504 "file_a.py": "# File contents",
3505 "file_b.py": "# File contents",
3506 "file_c.py": "# File contents",
3507 },
3508 "file_1.py": "# File contents",
3509 "file_2.py": "# File contents",
3510 "file_3.py": "# File contents",
3511 },
3512 "dir_2": {
3513 "file_1.py": "# File contents",
3514 "file_2.py": "# File contents",
3515 "file_3.py": "# File contents",
3516 }
3517 }),
3518 )
3519 .await;
3520
3521 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3522 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3523 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3524 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3525 cx.run_until_parked();
3526
3527 // Open project_root/dir_1 to ensure that a nested directory is expanded
3528 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3529 cx.executor().run_until_parked();
3530 assert_eq!(
3531 visible_entries_as_strings(&panel, 0..10, cx),
3532 &[
3533 "v project_root",
3534 " v dir_1 <== selected",
3535 " > nested_dir",
3536 " file_1.py",
3537 " file_2.py",
3538 " file_3.py",
3539 " > dir_2",
3540 ]
3541 );
3542
3543 // Close root directory
3544 toggle_expand_dir(&panel, "project_root", cx);
3545 cx.executor().run_until_parked();
3546 assert_eq!(
3547 visible_entries_as_strings(&panel, 0..10, cx),
3548 &["> project_root <== selected"]
3549 );
3550
3551 // Run collapse_all_entries and make sure root is not expanded
3552 panel.update_in(cx, |panel, window, cx| {
3553 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3554 });
3555 cx.executor().run_until_parked();
3556 assert_eq!(
3557 visible_entries_as_strings(&panel, 0..10, cx),
3558 &["> project_root <== selected"]
3559 );
3560}
3561
3562#[gpui::test]
3563async fn test_collapse_all_entries_with_invisible_worktree(cx: &mut gpui::TestAppContext) {
3564 init_test_with_editor(cx);
3565
3566 let fs = FakeFs::new(cx.executor());
3567 fs.insert_tree(
3568 "/project_root",
3569 json!({
3570 "dir_1": {
3571 "nested_dir": {
3572 "file_a.py": "# File contents",
3573 },
3574 "file_1.py": "# File contents",
3575 },
3576 "dir_2": {
3577 "file_1.py": "# File contents",
3578 }
3579 }),
3580 )
3581 .await;
3582 fs.insert_tree(
3583 "/external",
3584 json!({
3585 "external_file.py": "# External file",
3586 }),
3587 )
3588 .await;
3589
3590 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3591 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3592 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3593 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3594 cx.run_until_parked();
3595
3596 let (_invisible_worktree, _) = project
3597 .update(cx, |project, cx| {
3598 project.find_or_create_worktree("/external/external_file.py", false, cx)
3599 })
3600 .await
3601 .unwrap();
3602 cx.run_until_parked();
3603
3604 assert_eq!(
3605 visible_entries_as_strings(&panel, 0..10, cx),
3606 &["v project_root", " > dir_1", " > dir_2",],
3607 "invisible worktree should not appear in project panel"
3608 );
3609
3610 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3611 cx.executor().run_until_parked();
3612
3613 panel.update_in(cx, |panel, window, cx| {
3614 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3615 });
3616 cx.executor().run_until_parked();
3617 assert_eq!(
3618 visible_entries_as_strings(&panel, 0..10, cx),
3619 &["v project_root", " > dir_1 <== selected", " > dir_2",],
3620 "with single visible worktree, root should stay expanded even if invisible worktrees exist"
3621 );
3622}
3623
3624#[gpui::test]
3625async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3626 init_test(cx);
3627
3628 let fs = FakeFs::new(cx.executor());
3629 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
3630 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
3631 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3632 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3633 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3634 cx.run_until_parked();
3635
3636 // Make a new buffer with no backing file
3637 workspace
3638 .update(cx, |workspace, window, cx| {
3639 Editor::new_file(workspace, &Default::default(), window, cx)
3640 })
3641 .unwrap();
3642
3643 cx.executor().run_until_parked();
3644
3645 // "Save as" the buffer, creating a new backing file for it
3646 let save_task = workspace
3647 .update(cx, |workspace, window, cx| {
3648 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3649 })
3650 .unwrap();
3651
3652 cx.executor().run_until_parked();
3653 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
3654 save_task.await.unwrap();
3655
3656 // Rename the file
3657 select_path(&panel, "root/new", cx);
3658 assert_eq!(
3659 visible_entries_as_strings(&panel, 0..10, cx),
3660 &["v root", " new <== selected <== marked"]
3661 );
3662 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3663 panel.update_in(cx, |panel, window, cx| {
3664 panel
3665 .filename_editor
3666 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
3667 });
3668 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3669
3670 cx.executor().run_until_parked();
3671 assert_eq!(
3672 visible_entries_as_strings(&panel, 0..10, cx),
3673 &["v root", " newer <== selected"]
3674 );
3675
3676 workspace
3677 .update(cx, |workspace, window, cx| {
3678 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3679 })
3680 .unwrap()
3681 .await
3682 .unwrap();
3683
3684 cx.executor().run_until_parked();
3685 // assert that saving the file doesn't restore "new"
3686 assert_eq!(
3687 visible_entries_as_strings(&panel, 0..10, cx),
3688 &["v root", " newer <== selected"]
3689 );
3690}
3691
3692// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
3693// you can't rename a directory which some program has already open. This is a
3694// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
3695// to it, and thus renaming it will fail on Windows.
3696// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
3697// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
3698#[gpui::test]
3699#[cfg_attr(target_os = "windows", ignore)]
3700async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
3701 init_test_with_editor(cx);
3702
3703 let fs = FakeFs::new(cx.executor());
3704 fs.insert_tree(
3705 "/root1",
3706 json!({
3707 "dir1": {
3708 "file1.txt": "content 1",
3709 },
3710 }),
3711 )
3712 .await;
3713
3714 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3715 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3716 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3717 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3718 cx.run_until_parked();
3719
3720 toggle_expand_dir(&panel, "root1/dir1", cx);
3721
3722 assert_eq!(
3723 visible_entries_as_strings(&panel, 0..20, cx),
3724 &["v root1", " v dir1 <== selected", " file1.txt",],
3725 "Initial state with worktrees"
3726 );
3727
3728 select_path(&panel, "root1", cx);
3729 assert_eq!(
3730 visible_entries_as_strings(&panel, 0..20, cx),
3731 &["v root1 <== selected", " v dir1", " file1.txt",],
3732 );
3733
3734 // Rename root1 to new_root1
3735 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3736
3737 assert_eq!(
3738 visible_entries_as_strings(&panel, 0..20, cx),
3739 &[
3740 "v [EDITOR: 'root1'] <== selected",
3741 " v dir1",
3742 " file1.txt",
3743 ],
3744 );
3745
3746 let confirm = panel.update_in(cx, |panel, window, cx| {
3747 panel
3748 .filename_editor
3749 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3750 panel.confirm_edit(true, window, cx).unwrap()
3751 });
3752 confirm.await.unwrap();
3753 cx.run_until_parked();
3754 assert_eq!(
3755 visible_entries_as_strings(&panel, 0..20, cx),
3756 &[
3757 "v new_root1 <== selected",
3758 " v dir1",
3759 " file1.txt",
3760 ],
3761 "Should update worktree name"
3762 );
3763
3764 // Ensure internal paths have been updated
3765 select_path(&panel, "new_root1/dir1/file1.txt", cx);
3766 assert_eq!(
3767 visible_entries_as_strings(&panel, 0..20, cx),
3768 &[
3769 "v new_root1",
3770 " v dir1",
3771 " file1.txt <== selected",
3772 ],
3773 "Files in renamed worktree are selectable"
3774 );
3775}
3776
3777#[gpui::test]
3778async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3779 init_test_with_editor(cx);
3780
3781 let fs = FakeFs::new(cx.executor());
3782 fs.insert_tree(
3783 "/root1",
3784 json!({
3785 "dir1": { "file1.txt": "content" },
3786 "file2.txt": "content",
3787 }),
3788 )
3789 .await;
3790 fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3791 .await;
3792
3793 // Test 1: Single worktree, hide_root=true - rename should be blocked
3794 {
3795 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3796 let workspace =
3797 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3798 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3799
3800 cx.update(|_, cx| {
3801 let settings = *ProjectPanelSettings::get_global(cx);
3802 ProjectPanelSettings::override_global(
3803 ProjectPanelSettings {
3804 hide_root: true,
3805 ..settings
3806 },
3807 cx,
3808 );
3809 });
3810
3811 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3812 cx.run_until_parked();
3813
3814 panel.update(cx, |panel, cx| {
3815 let project = panel.project.read(cx);
3816 let worktree = project.visible_worktrees(cx).next().unwrap();
3817 let root_entry = worktree.read(cx).root_entry().unwrap();
3818 panel.selection = Some(SelectedEntry {
3819 worktree_id: worktree.read(cx).id(),
3820 entry_id: root_entry.id,
3821 });
3822 });
3823
3824 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3825
3826 assert!(
3827 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3828 "Rename should be blocked when hide_root=true with single worktree"
3829 );
3830 }
3831
3832 // Test 2: Multiple worktrees, hide_root=true - rename should work
3833 {
3834 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3835 let workspace =
3836 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3837 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3838
3839 cx.update(|_, cx| {
3840 let settings = *ProjectPanelSettings::get_global(cx);
3841 ProjectPanelSettings::override_global(
3842 ProjectPanelSettings {
3843 hide_root: true,
3844 ..settings
3845 },
3846 cx,
3847 );
3848 });
3849
3850 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3851 cx.run_until_parked();
3852
3853 select_path(&panel, "root1", cx);
3854 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3855
3856 #[cfg(target_os = "windows")]
3857 assert!(
3858 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3859 "Rename should be blocked on Windows even with multiple worktrees"
3860 );
3861
3862 #[cfg(not(target_os = "windows"))]
3863 {
3864 assert!(
3865 panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
3866 "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3867 );
3868 panel.update_in(cx, |panel, window, cx| {
3869 panel.cancel(&menu::Cancel, window, cx)
3870 });
3871 }
3872 }
3873}
3874
3875#[gpui::test]
3876async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3877 init_test_with_editor(cx);
3878 let fs = FakeFs::new(cx.executor());
3879 fs.insert_tree(
3880 "/project_root",
3881 json!({
3882 "dir_1": {
3883 "nested_dir": {
3884 "file_a.py": "# File contents",
3885 }
3886 },
3887 "file_1.py": "# File contents",
3888 }),
3889 )
3890 .await;
3891
3892 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3893 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3894 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3895 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3896 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3897 cx.run_until_parked();
3898
3899 cx.update(|window, cx| {
3900 panel.update(cx, |this, cx| {
3901 this.select_next(&Default::default(), window, cx);
3902 this.expand_selected_entry(&Default::default(), window, cx);
3903 })
3904 });
3905 cx.run_until_parked();
3906
3907 cx.update(|window, cx| {
3908 panel.update(cx, |this, cx| {
3909 this.expand_selected_entry(&Default::default(), window, cx);
3910 })
3911 });
3912 cx.run_until_parked();
3913
3914 cx.update(|window, cx| {
3915 panel.update(cx, |this, cx| {
3916 this.select_next(&Default::default(), window, cx);
3917 this.expand_selected_entry(&Default::default(), window, cx);
3918 })
3919 });
3920 cx.run_until_parked();
3921
3922 cx.update(|window, cx| {
3923 panel.update(cx, |this, cx| {
3924 this.select_next(&Default::default(), window, cx);
3925 })
3926 });
3927 cx.run_until_parked();
3928
3929 assert_eq!(
3930 visible_entries_as_strings(&panel, 0..10, cx),
3931 &[
3932 "v project_root",
3933 " v dir_1",
3934 " v nested_dir",
3935 " file_a.py <== selected",
3936 " file_1.py",
3937 ]
3938 );
3939 let modifiers_with_shift = gpui::Modifiers {
3940 shift: true,
3941 ..Default::default()
3942 };
3943 cx.run_until_parked();
3944 cx.simulate_modifiers_change(modifiers_with_shift);
3945 cx.update(|window, cx| {
3946 panel.update(cx, |this, cx| {
3947 this.select_next(&Default::default(), window, cx);
3948 })
3949 });
3950 assert_eq!(
3951 visible_entries_as_strings(&panel, 0..10, cx),
3952 &[
3953 "v project_root",
3954 " v dir_1",
3955 " v nested_dir",
3956 " file_a.py",
3957 " file_1.py <== selected <== marked",
3958 ]
3959 );
3960 cx.update(|window, cx| {
3961 panel.update(cx, |this, cx| {
3962 this.select_previous(&Default::default(), window, cx);
3963 })
3964 });
3965 assert_eq!(
3966 visible_entries_as_strings(&panel, 0..10, cx),
3967 &[
3968 "v project_root",
3969 " v dir_1",
3970 " v nested_dir",
3971 " file_a.py <== selected <== marked",
3972 " file_1.py <== marked",
3973 ]
3974 );
3975 cx.update(|window, cx| {
3976 panel.update(cx, |this, cx| {
3977 let drag = DraggedSelection {
3978 active_selection: this.selection.unwrap(),
3979 marked_selections: this.marked_entries.clone().into(),
3980 };
3981 let target_entry = this
3982 .project
3983 .read(cx)
3984 .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
3985 .unwrap();
3986 this.drag_onto(&drag, target_entry.id, false, window, cx);
3987 });
3988 });
3989 cx.run_until_parked();
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",
3996 " file_1.py <== marked",
3997 " file_a.py <== selected <== marked",
3998 ]
3999 );
4000 // ESC clears out all marks
4001 cx.update(|window, cx| {
4002 panel.update(cx, |this, cx| {
4003 this.cancel(&menu::Cancel, window, cx);
4004 })
4005 });
4006 cx.executor().run_until_parked();
4007 assert_eq!(
4008 visible_entries_as_strings(&panel, 0..10, cx),
4009 &[
4010 "v project_root",
4011 " v dir_1",
4012 " v nested_dir",
4013 " file_1.py",
4014 " file_a.py <== selected",
4015 ]
4016 );
4017 // ESC clears out all marks
4018 cx.update(|window, cx| {
4019 panel.update(cx, |this, cx| {
4020 this.select_previous(&SelectPrevious, window, cx);
4021 this.select_next(&SelectNext, window, cx);
4022 })
4023 });
4024 assert_eq!(
4025 visible_entries_as_strings(&panel, 0..10, cx),
4026 &[
4027 "v project_root",
4028 " v dir_1",
4029 " v nested_dir",
4030 " file_1.py <== marked",
4031 " file_a.py <== selected <== marked",
4032 ]
4033 );
4034 cx.simulate_modifiers_change(Default::default());
4035 cx.update(|window, cx| {
4036 panel.update(cx, |this, cx| {
4037 this.cut(&Cut, window, cx);
4038 this.select_previous(&SelectPrevious, window, cx);
4039 this.select_previous(&SelectPrevious, window, cx);
4040
4041 this.paste(&Paste, window, cx);
4042 this.update_visible_entries(None, false, false, window, cx);
4043 })
4044 });
4045 cx.run_until_parked();
4046 assert_eq!(
4047 visible_entries_as_strings(&panel, 0..10, cx),
4048 &[
4049 "v project_root",
4050 " v dir_1",
4051 " v nested_dir",
4052 " file_1.py <== marked",
4053 " file_a.py <== selected <== marked",
4054 ]
4055 );
4056 cx.simulate_modifiers_change(modifiers_with_shift);
4057 cx.update(|window, cx| {
4058 panel.update(cx, |this, cx| {
4059 this.expand_selected_entry(&Default::default(), window, cx);
4060 this.select_next(&SelectNext, window, cx);
4061 this.select_next(&SelectNext, window, cx);
4062 })
4063 });
4064 submit_deletion(&panel, cx);
4065 assert_eq!(
4066 visible_entries_as_strings(&panel, 0..10, cx),
4067 &[
4068 "v project_root",
4069 " v dir_1",
4070 " v nested_dir <== selected",
4071 ]
4072 );
4073}
4074
4075#[gpui::test]
4076async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
4077 init_test(cx);
4078
4079 let fs = FakeFs::new(cx.executor());
4080 fs.insert_tree(
4081 "/root",
4082 json!({
4083 "a": {
4084 "b": {
4085 "c": {
4086 "d": {}
4087 }
4088 }
4089 },
4090 "target_destination": {}
4091 }),
4092 )
4093 .await;
4094
4095 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4096 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4097 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4098
4099 cx.update(|_, cx| {
4100 let settings = *ProjectPanelSettings::get_global(cx);
4101 ProjectPanelSettings::override_global(
4102 ProjectPanelSettings {
4103 auto_fold_dirs: true,
4104 ..settings
4105 },
4106 cx,
4107 );
4108 });
4109
4110 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4111 cx.run_until_parked();
4112
4113 // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
4114 select_path(&panel, "root/a/b/c/d", cx);
4115 panel.update_in(cx, |panel, window, cx| {
4116 let drag = DraggedSelection {
4117 active_selection: *panel.selection.as_ref().unwrap(),
4118 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4119 };
4120 let target_entry = panel
4121 .project
4122 .read(cx)
4123 .visible_worktrees(cx)
4124 .next()
4125 .unwrap()
4126 .read(cx)
4127 .entry_for_path(rel_path("target_destination"))
4128 .unwrap();
4129 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4130 });
4131 cx.executor().run_until_parked();
4132
4133 assert_eq!(
4134 visible_entries_as_strings(&panel, 0..10, cx),
4135 &[
4136 "v root",
4137 " > a/b/c",
4138 " > target_destination/d <== selected"
4139 ],
4140 "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
4141 );
4142
4143 // Reset
4144 select_path(&panel, "root/target_destination/d", cx);
4145 panel.update_in(cx, |panel, window, cx| {
4146 let drag = DraggedSelection {
4147 active_selection: *panel.selection.as_ref().unwrap(),
4148 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4149 };
4150 let target_entry = panel
4151 .project
4152 .read(cx)
4153 .visible_worktrees(cx)
4154 .next()
4155 .unwrap()
4156 .read(cx)
4157 .entry_for_path(rel_path("a/b/c"))
4158 .unwrap();
4159 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4160 });
4161 cx.executor().run_until_parked();
4162
4163 // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
4164 select_path(&panel, "root/a/b", cx);
4165 panel.update_in(cx, |panel, window, cx| {
4166 let drag = DraggedSelection {
4167 active_selection: *panel.selection.as_ref().unwrap(),
4168 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4169 };
4170 let target_entry = panel
4171 .project
4172 .read(cx)
4173 .visible_worktrees(cx)
4174 .next()
4175 .unwrap()
4176 .read(cx)
4177 .entry_for_path(rel_path("target_destination"))
4178 .unwrap();
4179 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4180 });
4181 cx.executor().run_until_parked();
4182
4183 assert_eq!(
4184 visible_entries_as_strings(&panel, 0..10, cx),
4185 &["v root", " v a", " > target_destination/b/c/d"],
4186 "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
4187 );
4188
4189 // Reset
4190 select_path(&panel, "root/target_destination/b", cx);
4191 panel.update_in(cx, |panel, window, cx| {
4192 let drag = DraggedSelection {
4193 active_selection: *panel.selection.as_ref().unwrap(),
4194 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4195 };
4196 let target_entry = panel
4197 .project
4198 .read(cx)
4199 .visible_worktrees(cx)
4200 .next()
4201 .unwrap()
4202 .read(cx)
4203 .entry_for_path(rel_path("a"))
4204 .unwrap();
4205 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4206 });
4207 cx.executor().run_until_parked();
4208
4209 // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
4210 select_path(&panel, "root/a", cx);
4211 panel.update_in(cx, |panel, window, cx| {
4212 let drag = DraggedSelection {
4213 active_selection: *panel.selection.as_ref().unwrap(),
4214 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4215 };
4216 let target_entry = panel
4217 .project
4218 .read(cx)
4219 .visible_worktrees(cx)
4220 .next()
4221 .unwrap()
4222 .read(cx)
4223 .entry_for_path(rel_path("target_destination"))
4224 .unwrap();
4225 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4226 });
4227 cx.executor().run_until_parked();
4228
4229 assert_eq!(
4230 visible_entries_as_strings(&panel, 0..10, cx),
4231 &["v root", " > target_destination/a/b/c/d"],
4232 "Moving first directory 'a' should move whole 'a/b/c/d' chain"
4233 );
4234}
4235
4236#[gpui::test]
4237async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) {
4238 init_test(cx);
4239
4240 let fs = FakeFs::new(cx.executor());
4241 fs.insert_tree(
4242 "/root",
4243 json!({
4244 "a": {
4245 "b": {
4246 "c": {}
4247 }
4248 },
4249 "e": {
4250 "f": {
4251 "g": {}
4252 }
4253 },
4254 "target": {}
4255 }),
4256 )
4257 .await;
4258
4259 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4260 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4261 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4262
4263 cx.update(|_, cx| {
4264 let settings = *ProjectPanelSettings::get_global(cx);
4265 ProjectPanelSettings::override_global(
4266 ProjectPanelSettings {
4267 auto_fold_dirs: true,
4268 ..settings
4269 },
4270 cx,
4271 );
4272 });
4273
4274 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4275 cx.run_until_parked();
4276
4277 assert_eq!(
4278 visible_entries_as_strings(&panel, 0..10, cx),
4279 &["v root", " > a/b/c", " > e/f/g", " > target"]
4280 );
4281
4282 select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx);
4283 select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx);
4284
4285 panel.update_in(cx, |panel, window, cx| {
4286 let drag = DraggedSelection {
4287 active_selection: *panel.selection.as_ref().unwrap(),
4288 marked_selections: panel.marked_entries.clone().into(),
4289 };
4290 let target_entry = panel
4291 .project
4292 .read(cx)
4293 .visible_worktrees(cx)
4294 .next()
4295 .unwrap()
4296 .read(cx)
4297 .entry_for_path(rel_path("target"))
4298 .unwrap();
4299 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4300 });
4301 cx.executor().run_until_parked();
4302
4303 // After dragging 'b/c' and 'f/g' should be moved to target
4304 assert_eq!(
4305 visible_entries_as_strings(&panel, 0..10, cx),
4306 &[
4307 "v root",
4308 " > a",
4309 " > e",
4310 " v target",
4311 " > b/c",
4312 " > f/g <== selected <== marked"
4313 ],
4314 "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'"
4315 );
4316}
4317
4318#[gpui::test]
4319async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4320 init_test(cx);
4321
4322 let fs = FakeFs::new(cx.executor());
4323 fs.insert_tree(
4324 "/root_a",
4325 json!({
4326 "src": {
4327 "lib.rs": "",
4328 "main.rs": ""
4329 },
4330 "docs": {
4331 "guide.md": ""
4332 },
4333 "multi": {
4334 "alpha.txt": "",
4335 "beta.txt": ""
4336 }
4337 }),
4338 )
4339 .await;
4340 fs.insert_tree(
4341 "/root_b",
4342 json!({
4343 "dst": {
4344 "existing.md": ""
4345 },
4346 "target.txt": ""
4347 }),
4348 )
4349 .await;
4350
4351 let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4352 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4353 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4354 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4355 cx.run_until_parked();
4356
4357 // Case 1: move a file onto a directory in another worktree.
4358 select_path(&panel, "root_a/src/main.rs", cx);
4359 drag_selection_to(&panel, "root_b/dst", false, cx);
4360 assert!(
4361 find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4362 "Dragged file should appear under destination worktree"
4363 );
4364 assert_eq!(
4365 find_project_entry(&panel, "root_a/src/main.rs", cx),
4366 None,
4367 "Dragged file should be removed from the source worktree"
4368 );
4369
4370 // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4371 select_path(&panel, "root_a/docs/guide.md", cx);
4372 drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4373 assert!(
4374 find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4375 "Dropping onto a file should place the entry beside the target file"
4376 );
4377 assert_eq!(
4378 find_project_entry(&panel, "root_a/docs/guide.md", cx),
4379 None,
4380 "Source file should be removed after the move"
4381 );
4382
4383 // Case 3: move an entire directory.
4384 select_path(&panel, "root_a/src", cx);
4385 drag_selection_to(&panel, "root_b/dst", false, cx);
4386 assert!(
4387 find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4388 "Dragging a directory should move its nested contents"
4389 );
4390 assert_eq!(
4391 find_project_entry(&panel, "root_a/src", cx),
4392 None,
4393 "Directory should no longer exist in the source worktree"
4394 );
4395
4396 // Case 4: multi-selection drag between worktrees.
4397 panel.update(cx, |panel, _| panel.marked_entries.clear());
4398 select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4399 select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4400 drag_selection_to(&panel, "root_b/dst", false, cx);
4401 assert!(
4402 find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4403 && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4404 "All marked entries should move to the destination worktree"
4405 );
4406 assert_eq!(
4407 find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4408 None,
4409 "Marked entries should be removed from the origin worktree"
4410 );
4411 assert_eq!(
4412 find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4413 None,
4414 "Marked entries should be removed from the origin worktree"
4415 );
4416}
4417
4418#[gpui::test]
4419async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4420 init_test(cx);
4421
4422 let fs = FakeFs::new(cx.executor());
4423 fs.insert_tree(
4424 "/root",
4425 json!({
4426 "src": {
4427 "folder1": {
4428 "mod.rs": "// folder1 mod"
4429 },
4430 "folder2": {
4431 "mod.rs": "// folder2 mod"
4432 },
4433 "folder3": {
4434 "mod.rs": "// folder3 mod",
4435 "helper.rs": "// helper"
4436 },
4437 "main.rs": ""
4438 }
4439 }),
4440 )
4441 .await;
4442
4443 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4444 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4445 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4446 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4447 cx.run_until_parked();
4448
4449 toggle_expand_dir(&panel, "root/src", cx);
4450 toggle_expand_dir(&panel, "root/src/folder1", cx);
4451 toggle_expand_dir(&panel, "root/src/folder2", cx);
4452 toggle_expand_dir(&panel, "root/src/folder3", cx);
4453 cx.run_until_parked();
4454
4455 // Case 1: Dragging a folder and a file from a sibling folder together.
4456 panel.update(cx, |panel, _| panel.marked_entries.clear());
4457 select_path_with_mark(&panel, "root/src/folder1", cx);
4458 select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4459
4460 drag_selection_to(&panel, "root", false, cx);
4461
4462 assert!(
4463 find_project_entry(&panel, "root/folder1", cx).is_some(),
4464 "folder1 should be at root after drag"
4465 );
4466 assert!(
4467 find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4468 "folder1/mod.rs should still be inside folder1 after drag"
4469 );
4470 assert_eq!(
4471 find_project_entry(&panel, "root/src/folder1", cx),
4472 None,
4473 "folder1 should no longer be in src"
4474 );
4475 assert!(
4476 find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4477 "mod.rs from folder2 should be at root"
4478 );
4479
4480 // Case 2: Dragging a folder and its own child together.
4481 panel.update(cx, |panel, _| panel.marked_entries.clear());
4482 select_path_with_mark(&panel, "root/src/folder3", cx);
4483 select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4484
4485 drag_selection_to(&panel, "root", false, cx);
4486
4487 assert!(
4488 find_project_entry(&panel, "root/folder3", cx).is_some(),
4489 "folder3 should be at root after drag"
4490 );
4491 assert!(
4492 find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4493 "folder3/mod.rs should still be inside folder3"
4494 );
4495 assert!(
4496 find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4497 "folder3/helper.rs should still be inside folder3"
4498 );
4499}
4500
4501#[gpui::test]
4502async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4503 init_test_with_editor(cx);
4504 cx.update(|cx| {
4505 cx.update_global::<SettingsStore, _>(|store, cx| {
4506 store.update_user_settings(cx, |settings| {
4507 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4508 settings
4509 .project_panel
4510 .get_or_insert_default()
4511 .auto_reveal_entries = Some(false);
4512 });
4513 })
4514 });
4515
4516 let fs = FakeFs::new(cx.background_executor.clone());
4517 fs.insert_tree(
4518 "/project_root",
4519 json!({
4520 ".git": {},
4521 ".gitignore": "**/gitignored_dir",
4522 "dir_1": {
4523 "file_1.py": "# File 1_1 contents",
4524 "file_2.py": "# File 1_2 contents",
4525 "file_3.py": "# File 1_3 contents",
4526 "gitignored_dir": {
4527 "file_a.py": "# File contents",
4528 "file_b.py": "# File contents",
4529 "file_c.py": "# File contents",
4530 },
4531 },
4532 "dir_2": {
4533 "file_1.py": "# File 2_1 contents",
4534 "file_2.py": "# File 2_2 contents",
4535 "file_3.py": "# File 2_3 contents",
4536 }
4537 }),
4538 )
4539 .await;
4540
4541 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4542 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4543 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4544 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4545 cx.run_until_parked();
4546
4547 assert_eq!(
4548 visible_entries_as_strings(&panel, 0..20, cx),
4549 &[
4550 "v project_root",
4551 " > .git",
4552 " > dir_1",
4553 " > dir_2",
4554 " .gitignore",
4555 ]
4556 );
4557
4558 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4559 .expect("dir 1 file is not ignored and should have an entry");
4560 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4561 .expect("dir 2 file is not ignored and should have an entry");
4562 let gitignored_dir_file =
4563 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4564 assert_eq!(
4565 gitignored_dir_file, None,
4566 "File in the gitignored dir should not have an entry before its dir is toggled"
4567 );
4568
4569 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4570 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4571 cx.executor().run_until_parked();
4572 assert_eq!(
4573 visible_entries_as_strings(&panel, 0..20, cx),
4574 &[
4575 "v project_root",
4576 " > .git",
4577 " v dir_1",
4578 " v gitignored_dir <== selected",
4579 " file_a.py",
4580 " file_b.py",
4581 " file_c.py",
4582 " file_1.py",
4583 " file_2.py",
4584 " file_3.py",
4585 " > dir_2",
4586 " .gitignore",
4587 ],
4588 "Should show gitignored dir file list in the project panel"
4589 );
4590 let gitignored_dir_file =
4591 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4592 .expect("after gitignored dir got opened, a file entry should be present");
4593
4594 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4595 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4596 assert_eq!(
4597 visible_entries_as_strings(&panel, 0..20, cx),
4598 &[
4599 "v project_root",
4600 " > .git",
4601 " > dir_1 <== selected",
4602 " > dir_2",
4603 " .gitignore",
4604 ],
4605 "Should hide all dir contents again and prepare for the auto reveal test"
4606 );
4607
4608 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4609 panel.update(cx, |panel, cx| {
4610 panel.project.update(cx, |_, cx| {
4611 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4612 })
4613 });
4614 cx.run_until_parked();
4615 assert_eq!(
4616 visible_entries_as_strings(&panel, 0..20, cx),
4617 &[
4618 "v project_root",
4619 " > .git",
4620 " > dir_1 <== selected",
4621 " > dir_2",
4622 " .gitignore",
4623 ],
4624 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4625 );
4626 }
4627
4628 cx.update(|_, cx| {
4629 cx.update_global::<SettingsStore, _>(|store, cx| {
4630 store.update_user_settings(cx, |settings| {
4631 settings
4632 .project_panel
4633 .get_or_insert_default()
4634 .auto_reveal_entries = Some(true)
4635 });
4636 })
4637 });
4638
4639 panel.update(cx, |panel, cx| {
4640 panel.project.update(cx, |_, cx| {
4641 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4642 })
4643 });
4644 cx.run_until_parked();
4645 assert_eq!(
4646 visible_entries_as_strings(&panel, 0..20, cx),
4647 &[
4648 "v project_root",
4649 " > .git",
4650 " v dir_1",
4651 " > gitignored_dir",
4652 " file_1.py <== selected <== marked",
4653 " file_2.py",
4654 " file_3.py",
4655 " > dir_2",
4656 " .gitignore",
4657 ],
4658 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4659 );
4660
4661 panel.update(cx, |panel, cx| {
4662 panel.project.update(cx, |_, cx| {
4663 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4664 })
4665 });
4666 cx.run_until_parked();
4667 assert_eq!(
4668 visible_entries_as_strings(&panel, 0..20, cx),
4669 &[
4670 "v project_root",
4671 " > .git",
4672 " v dir_1",
4673 " > gitignored_dir",
4674 " file_1.py",
4675 " file_2.py",
4676 " file_3.py",
4677 " v dir_2",
4678 " file_1.py <== selected <== marked",
4679 " file_2.py",
4680 " file_3.py",
4681 " .gitignore",
4682 ],
4683 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4684 );
4685
4686 panel.update(cx, |panel, cx| {
4687 panel.project.update(cx, |_, cx| {
4688 cx.emit(project::Event::ActiveEntryChanged(Some(
4689 gitignored_dir_file,
4690 )))
4691 })
4692 });
4693 cx.run_until_parked();
4694 assert_eq!(
4695 visible_entries_as_strings(&panel, 0..20, cx),
4696 &[
4697 "v project_root",
4698 " > .git",
4699 " v dir_1",
4700 " > gitignored_dir",
4701 " file_1.py",
4702 " file_2.py",
4703 " file_3.py",
4704 " v dir_2",
4705 " file_1.py <== selected <== marked",
4706 " file_2.py",
4707 " file_3.py",
4708 " .gitignore",
4709 ],
4710 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4711 );
4712
4713 panel.update(cx, |panel, cx| {
4714 panel.project.update(cx, |_, cx| {
4715 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4716 })
4717 });
4718 cx.run_until_parked();
4719 assert_eq!(
4720 visible_entries_as_strings(&panel, 0..20, cx),
4721 &[
4722 "v project_root",
4723 " > .git",
4724 " v dir_1",
4725 " v gitignored_dir",
4726 " file_a.py <== selected <== marked",
4727 " file_b.py",
4728 " file_c.py",
4729 " file_1.py",
4730 " file_2.py",
4731 " file_3.py",
4732 " v dir_2",
4733 " file_1.py",
4734 " file_2.py",
4735 " file_3.py",
4736 " .gitignore",
4737 ],
4738 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4739 );
4740}
4741
4742#[gpui::test]
4743async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
4744 init_test_with_editor(cx);
4745 cx.update(|cx| {
4746 cx.update_global::<SettingsStore, _>(|store, cx| {
4747 store.update_user_settings(cx, |settings| {
4748 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4749 settings.project.worktree.file_scan_inclusions =
4750 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
4751 settings
4752 .project_panel
4753 .get_or_insert_default()
4754 .auto_reveal_entries = Some(false)
4755 });
4756 })
4757 });
4758
4759 let fs = FakeFs::new(cx.background_executor.clone());
4760 fs.insert_tree(
4761 "/project_root",
4762 json!({
4763 ".git": {},
4764 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
4765 "dir_1": {
4766 "file_1.py": "# File 1_1 contents",
4767 "file_2.py": "# File 1_2 contents",
4768 "file_3.py": "# File 1_3 contents",
4769 "gitignored_dir": {
4770 "file_a.py": "# File contents",
4771 "file_b.py": "# File contents",
4772 "file_c.py": "# File contents",
4773 },
4774 },
4775 "dir_2": {
4776 "file_1.py": "# File 2_1 contents",
4777 "file_2.py": "# File 2_2 contents",
4778 "file_3.py": "# File 2_3 contents",
4779 },
4780 "always_included_but_ignored_dir": {
4781 "file_a.py": "# File contents",
4782 "file_b.py": "# File contents",
4783 "file_c.py": "# File contents",
4784 },
4785 }),
4786 )
4787 .await;
4788
4789 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4790 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4791 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4792 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4793 cx.run_until_parked();
4794
4795 assert_eq!(
4796 visible_entries_as_strings(&panel, 0..20, cx),
4797 &[
4798 "v project_root",
4799 " > .git",
4800 " > always_included_but_ignored_dir",
4801 " > dir_1",
4802 " > dir_2",
4803 " .gitignore",
4804 ]
4805 );
4806
4807 let gitignored_dir_file =
4808 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4809 let always_included_but_ignored_dir_file = find_project_entry(
4810 &panel,
4811 "project_root/always_included_but_ignored_dir/file_a.py",
4812 cx,
4813 )
4814 .expect("file that is .gitignored but set to always be included should have an entry");
4815 assert_eq!(
4816 gitignored_dir_file, None,
4817 "File in the gitignored dir should not have an entry unless its directory is toggled"
4818 );
4819
4820 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4821 cx.run_until_parked();
4822 cx.update(|_, cx| {
4823 cx.update_global::<SettingsStore, _>(|store, cx| {
4824 store.update_user_settings(cx, |settings| {
4825 settings
4826 .project_panel
4827 .get_or_insert_default()
4828 .auto_reveal_entries = Some(true)
4829 });
4830 })
4831 });
4832
4833 panel.update(cx, |panel, cx| {
4834 panel.project.update(cx, |_, cx| {
4835 cx.emit(project::Event::ActiveEntryChanged(Some(
4836 always_included_but_ignored_dir_file,
4837 )))
4838 })
4839 });
4840 cx.run_until_parked();
4841
4842 assert_eq!(
4843 visible_entries_as_strings(&panel, 0..20, cx),
4844 &[
4845 "v project_root",
4846 " > .git",
4847 " v always_included_but_ignored_dir",
4848 " file_a.py <== selected <== marked",
4849 " file_b.py",
4850 " file_c.py",
4851 " v dir_1",
4852 " > gitignored_dir",
4853 " file_1.py",
4854 " file_2.py",
4855 " file_3.py",
4856 " > dir_2",
4857 " .gitignore",
4858 ],
4859 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
4860 );
4861}
4862
4863#[gpui::test]
4864async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4865 init_test_with_editor(cx);
4866 cx.update(|cx| {
4867 cx.update_global::<SettingsStore, _>(|store, cx| {
4868 store.update_user_settings(cx, |settings| {
4869 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4870 settings
4871 .project_panel
4872 .get_or_insert_default()
4873 .auto_reveal_entries = Some(false)
4874 });
4875 })
4876 });
4877
4878 let fs = FakeFs::new(cx.background_executor.clone());
4879 fs.insert_tree(
4880 "/project_root",
4881 json!({
4882 ".git": {},
4883 ".gitignore": "**/gitignored_dir",
4884 "dir_1": {
4885 "file_1.py": "# File 1_1 contents",
4886 "file_2.py": "# File 1_2 contents",
4887 "file_3.py": "# File 1_3 contents",
4888 "gitignored_dir": {
4889 "file_a.py": "# File contents",
4890 "file_b.py": "# File contents",
4891 "file_c.py": "# File contents",
4892 },
4893 },
4894 "dir_2": {
4895 "file_1.py": "# File 2_1 contents",
4896 "file_2.py": "# File 2_2 contents",
4897 "file_3.py": "# File 2_3 contents",
4898 }
4899 }),
4900 )
4901 .await;
4902
4903 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4904 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4905 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4906 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4907 cx.run_until_parked();
4908
4909 assert_eq!(
4910 visible_entries_as_strings(&panel, 0..20, cx),
4911 &[
4912 "v project_root",
4913 " > .git",
4914 " > dir_1",
4915 " > dir_2",
4916 " .gitignore",
4917 ]
4918 );
4919
4920 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4921 .expect("dir 1 file is not ignored and should have an entry");
4922 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4923 .expect("dir 2 file is not ignored and should have an entry");
4924 let gitignored_dir_file =
4925 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4926 assert_eq!(
4927 gitignored_dir_file, None,
4928 "File in the gitignored dir should not have an entry before its dir is toggled"
4929 );
4930
4931 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4932 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4933 cx.run_until_parked();
4934 assert_eq!(
4935 visible_entries_as_strings(&panel, 0..20, cx),
4936 &[
4937 "v project_root",
4938 " > .git",
4939 " v dir_1",
4940 " v gitignored_dir <== selected",
4941 " file_a.py",
4942 " file_b.py",
4943 " file_c.py",
4944 " file_1.py",
4945 " file_2.py",
4946 " file_3.py",
4947 " > dir_2",
4948 " .gitignore",
4949 ],
4950 "Should show gitignored dir file list in the project panel"
4951 );
4952 let gitignored_dir_file =
4953 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4954 .expect("after gitignored dir got opened, a file entry should be present");
4955
4956 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4957 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4958 assert_eq!(
4959 visible_entries_as_strings(&panel, 0..20, cx),
4960 &[
4961 "v project_root",
4962 " > .git",
4963 " > dir_1 <== selected",
4964 " > dir_2",
4965 " .gitignore",
4966 ],
4967 "Should hide all dir contents again and prepare for the explicit reveal test"
4968 );
4969
4970 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4971 panel.update(cx, |panel, cx| {
4972 panel.project.update(cx, |_, cx| {
4973 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4974 })
4975 });
4976 cx.run_until_parked();
4977 assert_eq!(
4978 visible_entries_as_strings(&panel, 0..20, cx),
4979 &[
4980 "v project_root",
4981 " > .git",
4982 " > dir_1 <== selected",
4983 " > dir_2",
4984 " .gitignore",
4985 ],
4986 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4987 );
4988 }
4989
4990 panel.update(cx, |panel, cx| {
4991 panel.project.update(cx, |_, cx| {
4992 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4993 })
4994 });
4995 cx.run_until_parked();
4996 assert_eq!(
4997 visible_entries_as_strings(&panel, 0..20, cx),
4998 &[
4999 "v project_root",
5000 " > .git",
5001 " v dir_1",
5002 " > gitignored_dir",
5003 " file_1.py <== selected <== marked",
5004 " file_2.py",
5005 " file_3.py",
5006 " > dir_2",
5007 " .gitignore",
5008 ],
5009 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5010 );
5011
5012 panel.update(cx, |panel, cx| {
5013 panel.project.update(cx, |_, cx| {
5014 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5015 })
5016 });
5017 cx.run_until_parked();
5018 assert_eq!(
5019 visible_entries_as_strings(&panel, 0..20, cx),
5020 &[
5021 "v project_root",
5022 " > .git",
5023 " v dir_1",
5024 " > gitignored_dir",
5025 " file_1.py",
5026 " file_2.py",
5027 " file_3.py",
5028 " v dir_2",
5029 " file_1.py <== selected <== marked",
5030 " file_2.py",
5031 " file_3.py",
5032 " .gitignore",
5033 ],
5034 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5035 );
5036
5037 panel.update(cx, |panel, cx| {
5038 panel.project.update(cx, |_, cx| {
5039 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5040 })
5041 });
5042 cx.run_until_parked();
5043 assert_eq!(
5044 visible_entries_as_strings(&panel, 0..20, cx),
5045 &[
5046 "v project_root",
5047 " > .git",
5048 " v dir_1",
5049 " v gitignored_dir",
5050 " file_a.py <== selected <== marked",
5051 " file_b.py",
5052 " file_c.py",
5053 " file_1.py",
5054 " file_2.py",
5055 " file_3.py",
5056 " v dir_2",
5057 " file_1.py",
5058 " file_2.py",
5059 " file_3.py",
5060 " .gitignore",
5061 ],
5062 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5063 );
5064}
5065
5066#[gpui::test]
5067async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5068 init_test(cx);
5069 cx.update(|cx| {
5070 cx.update_global::<SettingsStore, _>(|store, cx| {
5071 store.update_user_settings(cx, |settings| {
5072 settings.project.worktree.file_scan_exclusions =
5073 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5074 });
5075 });
5076 });
5077
5078 cx.update(|cx| {
5079 register_project_item::<TestProjectItemView>(cx);
5080 });
5081
5082 let fs = FakeFs::new(cx.executor());
5083 fs.insert_tree(
5084 "/root1",
5085 json!({
5086 ".dockerignore": "",
5087 ".git": {
5088 "HEAD": "",
5089 },
5090 }),
5091 )
5092 .await;
5093
5094 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5095 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5096 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5097 let panel = workspace
5098 .update(cx, |workspace, window, cx| {
5099 let panel = ProjectPanel::new(workspace, window, cx);
5100 workspace.add_panel(panel.clone(), window, cx);
5101 panel
5102 })
5103 .unwrap();
5104 cx.run_until_parked();
5105
5106 select_path(&panel, "root1", cx);
5107 assert_eq!(
5108 visible_entries_as_strings(&panel, 0..10, cx),
5109 &["v root1 <== selected", " .dockerignore",]
5110 );
5111 workspace
5112 .update(cx, |workspace, _, cx| {
5113 assert!(
5114 workspace.active_item(cx).is_none(),
5115 "Should have no active items in the beginning"
5116 );
5117 })
5118 .unwrap();
5119
5120 let excluded_file_path = ".git/COMMIT_EDITMSG";
5121 let excluded_dir_path = "excluded_dir";
5122
5123 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5124 cx.run_until_parked();
5125 panel.update_in(cx, |panel, window, cx| {
5126 assert!(panel.filename_editor.read(cx).is_focused(window));
5127 });
5128 panel
5129 .update_in(cx, |panel, window, cx| {
5130 panel.filename_editor.update(cx, |editor, cx| {
5131 editor.set_text(excluded_file_path, window, cx)
5132 });
5133 panel.confirm_edit(true, window, cx).unwrap()
5134 })
5135 .await
5136 .unwrap();
5137
5138 assert_eq!(
5139 visible_entries_as_strings(&panel, 0..13, cx),
5140 &["v root1", " .dockerignore"],
5141 "Excluded dir should not be shown after opening a file in it"
5142 );
5143 panel.update_in(cx, |panel, window, cx| {
5144 assert!(
5145 !panel.filename_editor.read(cx).is_focused(window),
5146 "Should have closed the file name editor"
5147 );
5148 });
5149 workspace
5150 .update(cx, |workspace, _, cx| {
5151 let active_entry_path = workspace
5152 .active_item(cx)
5153 .expect("should have opened and activated the excluded item")
5154 .act_as::<TestProjectItemView>(cx)
5155 .expect("should have opened the corresponding project item for the excluded item")
5156 .read(cx)
5157 .path
5158 .clone();
5159 assert_eq!(
5160 active_entry_path.path.as_ref(),
5161 rel_path(excluded_file_path),
5162 "Should open the excluded file"
5163 );
5164
5165 assert!(
5166 workspace.notification_ids().is_empty(),
5167 "Should have no notifications after opening an excluded file"
5168 );
5169 })
5170 .unwrap();
5171 assert!(
5172 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5173 "Should have created the excluded file"
5174 );
5175
5176 select_path(&panel, "root1", cx);
5177 panel.update_in(cx, |panel, window, cx| {
5178 panel.new_directory(&NewDirectory, window, cx)
5179 });
5180 cx.run_until_parked();
5181 panel.update_in(cx, |panel, window, cx| {
5182 assert!(panel.filename_editor.read(cx).is_focused(window));
5183 });
5184 panel
5185 .update_in(cx, |panel, window, cx| {
5186 panel.filename_editor.update(cx, |editor, cx| {
5187 editor.set_text(excluded_file_path, window, cx)
5188 });
5189 panel.confirm_edit(true, window, cx).unwrap()
5190 })
5191 .await
5192 .unwrap();
5193 cx.run_until_parked();
5194 assert_eq!(
5195 visible_entries_as_strings(&panel, 0..13, cx),
5196 &["v root1", " .dockerignore"],
5197 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5198 );
5199 panel.update_in(cx, |panel, window, cx| {
5200 assert!(
5201 !panel.filename_editor.read(cx).is_focused(window),
5202 "Should have closed the file name editor"
5203 );
5204 });
5205 workspace
5206 .update(cx, |workspace, _, cx| {
5207 let notifications = workspace.notification_ids();
5208 assert_eq!(
5209 notifications.len(),
5210 1,
5211 "Should receive one notification with the error message"
5212 );
5213 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5214 assert!(workspace.notification_ids().is_empty());
5215 })
5216 .unwrap();
5217
5218 select_path(&panel, "root1", cx);
5219 panel.update_in(cx, |panel, window, cx| {
5220 panel.new_directory(&NewDirectory, window, cx)
5221 });
5222 cx.run_until_parked();
5223
5224 panel.update_in(cx, |panel, window, cx| {
5225 assert!(panel.filename_editor.read(cx).is_focused(window));
5226 });
5227
5228 panel
5229 .update_in(cx, |panel, window, cx| {
5230 panel.filename_editor.update(cx, |editor, cx| {
5231 editor.set_text(excluded_dir_path, window, cx)
5232 });
5233 panel.confirm_edit(true, window, cx).unwrap()
5234 })
5235 .await
5236 .unwrap();
5237
5238 cx.run_until_parked();
5239
5240 assert_eq!(
5241 visible_entries_as_strings(&panel, 0..13, cx),
5242 &["v root1", " .dockerignore"],
5243 "Should not change the project panel after trying to create an excluded directory"
5244 );
5245 panel.update_in(cx, |panel, window, cx| {
5246 assert!(
5247 !panel.filename_editor.read(cx).is_focused(window),
5248 "Should have closed the file name editor"
5249 );
5250 });
5251 workspace
5252 .update(cx, |workspace, _, cx| {
5253 let notifications = workspace.notification_ids();
5254 assert_eq!(
5255 notifications.len(),
5256 1,
5257 "Should receive one notification explaining that no directory is actually shown"
5258 );
5259 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5260 assert!(workspace.notification_ids().is_empty());
5261 })
5262 .unwrap();
5263 assert!(
5264 fs.is_dir(Path::new("/root1/excluded_dir")).await,
5265 "Should have created the excluded directory"
5266 );
5267}
5268
5269#[gpui::test]
5270async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5271 init_test_with_editor(cx);
5272
5273 let fs = FakeFs::new(cx.executor());
5274 fs.insert_tree(
5275 "/src",
5276 json!({
5277 "test": {
5278 "first.rs": "// First Rust file",
5279 "second.rs": "// Second Rust file",
5280 "third.rs": "// Third Rust file",
5281 }
5282 }),
5283 )
5284 .await;
5285
5286 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5287 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5288 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5289 let panel = workspace
5290 .update(cx, |workspace, window, cx| {
5291 let panel = ProjectPanel::new(workspace, window, cx);
5292 workspace.add_panel(panel.clone(), window, cx);
5293 panel
5294 })
5295 .unwrap();
5296 cx.run_until_parked();
5297
5298 select_path(&panel, "src", cx);
5299 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5300 cx.executor().run_until_parked();
5301 assert_eq!(
5302 visible_entries_as_strings(&panel, 0..10, cx),
5303 &[
5304 //
5305 "v src <== selected",
5306 " > test"
5307 ]
5308 );
5309 panel.update_in(cx, |panel, window, cx| {
5310 panel.new_directory(&NewDirectory, window, cx)
5311 });
5312 cx.executor().run_until_parked();
5313 panel.update_in(cx, |panel, window, cx| {
5314 assert!(panel.filename_editor.read(cx).is_focused(window));
5315 });
5316 assert_eq!(
5317 visible_entries_as_strings(&panel, 0..10, cx),
5318 &[
5319 //
5320 "v src",
5321 " > [EDITOR: ''] <== selected",
5322 " > test"
5323 ]
5324 );
5325
5326 panel.update_in(cx, |panel, window, cx| {
5327 panel.cancel(&menu::Cancel, window, cx);
5328 });
5329 cx.executor().run_until_parked();
5330 assert_eq!(
5331 visible_entries_as_strings(&panel, 0..10, cx),
5332 &[
5333 //
5334 "v src <== selected",
5335 " > test"
5336 ]
5337 );
5338
5339 panel.update_in(cx, |panel, window, cx| {
5340 panel.new_directory(&NewDirectory, window, cx)
5341 });
5342 cx.executor().run_until_parked();
5343 panel.update_in(cx, |panel, window, cx| {
5344 assert!(panel.filename_editor.read(cx).is_focused(window));
5345 });
5346 assert_eq!(
5347 visible_entries_as_strings(&panel, 0..10, cx),
5348 &[
5349 //
5350 "v src",
5351 " > [EDITOR: ''] <== selected",
5352 " > test"
5353 ]
5354 );
5355 workspace.update(cx, |_, window, _| window.blur()).unwrap();
5356 cx.executor().run_until_parked();
5357 assert_eq!(
5358 visible_entries_as_strings(&panel, 0..10, cx),
5359 &[
5360 //
5361 "v src <== selected",
5362 " > test"
5363 ]
5364 );
5365}
5366
5367#[gpui::test]
5368async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5369 init_test_with_editor(cx);
5370
5371 let fs = FakeFs::new(cx.executor());
5372 fs.insert_tree(
5373 "/root",
5374 json!({
5375 "dir1": {
5376 "subdir1": {},
5377 "file1.txt": "",
5378 "file2.txt": "",
5379 },
5380 "dir2": {
5381 "subdir2": {},
5382 "file3.txt": "",
5383 "file4.txt": "",
5384 },
5385 "file5.txt": "",
5386 "file6.txt": "",
5387 }),
5388 )
5389 .await;
5390
5391 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5392 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5393 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5394 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5395 cx.run_until_parked();
5396
5397 toggle_expand_dir(&panel, "root/dir1", cx);
5398 toggle_expand_dir(&panel, "root/dir2", cx);
5399
5400 // Test Case 1: Delete middle file in directory
5401 select_path(&panel, "root/dir1/file1.txt", cx);
5402 assert_eq!(
5403 visible_entries_as_strings(&panel, 0..15, cx),
5404 &[
5405 "v root",
5406 " v dir1",
5407 " > subdir1",
5408 " file1.txt <== selected",
5409 " file2.txt",
5410 " v dir2",
5411 " > subdir2",
5412 " file3.txt",
5413 " file4.txt",
5414 " file5.txt",
5415 " file6.txt",
5416 ],
5417 "Initial state before deleting middle file"
5418 );
5419
5420 submit_deletion(&panel, cx);
5421 assert_eq!(
5422 visible_entries_as_strings(&panel, 0..15, cx),
5423 &[
5424 "v root",
5425 " v dir1",
5426 " > subdir1",
5427 " file2.txt <== selected",
5428 " v dir2",
5429 " > subdir2",
5430 " file3.txt",
5431 " file4.txt",
5432 " file5.txt",
5433 " file6.txt",
5434 ],
5435 "Should select next file after deleting middle file"
5436 );
5437
5438 // Test Case 2: Delete last file in directory
5439 submit_deletion(&panel, cx);
5440 assert_eq!(
5441 visible_entries_as_strings(&panel, 0..15, cx),
5442 &[
5443 "v root",
5444 " v dir1",
5445 " > subdir1 <== selected",
5446 " v dir2",
5447 " > subdir2",
5448 " file3.txt",
5449 " file4.txt",
5450 " file5.txt",
5451 " file6.txt",
5452 ],
5453 "Should select next directory when last file is deleted"
5454 );
5455
5456 // Test Case 3: Delete root level file
5457 select_path(&panel, "root/file6.txt", cx);
5458 assert_eq!(
5459 visible_entries_as_strings(&panel, 0..15, cx),
5460 &[
5461 "v root",
5462 " v dir1",
5463 " > subdir1",
5464 " v dir2",
5465 " > subdir2",
5466 " file3.txt",
5467 " file4.txt",
5468 " file5.txt",
5469 " file6.txt <== selected",
5470 ],
5471 "Initial state before deleting root level file"
5472 );
5473
5474 submit_deletion(&panel, cx);
5475 assert_eq!(
5476 visible_entries_as_strings(&panel, 0..15, cx),
5477 &[
5478 "v root",
5479 " v dir1",
5480 " > subdir1",
5481 " v dir2",
5482 " > subdir2",
5483 " file3.txt",
5484 " file4.txt",
5485 " file5.txt <== selected",
5486 ],
5487 "Should select prev entry at root level"
5488 );
5489}
5490
5491#[gpui::test]
5492async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
5493 init_test_with_editor(cx);
5494
5495 let fs = FakeFs::new(cx.executor());
5496 fs.insert_tree(
5497 path!("/root"),
5498 json!({
5499 "aa": "// Testing 1",
5500 "bb": "// Testing 2",
5501 "cc": "// Testing 3",
5502 "dd": "// Testing 4",
5503 "ee": "// Testing 5",
5504 "ff": "// Testing 6",
5505 "gg": "// Testing 7",
5506 "hh": "// Testing 8",
5507 "ii": "// Testing 8",
5508 ".gitignore": "bb\ndd\nee\nff\nii\n'",
5509 }),
5510 )
5511 .await;
5512
5513 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5514 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5515 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5516
5517 // Test 1: Auto selection with one gitignored file next to the deleted file
5518 cx.update(|_, cx| {
5519 let settings = *ProjectPanelSettings::get_global(cx);
5520 ProjectPanelSettings::override_global(
5521 ProjectPanelSettings {
5522 hide_gitignore: true,
5523 ..settings
5524 },
5525 cx,
5526 );
5527 });
5528
5529 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5530 cx.run_until_parked();
5531
5532 select_path(&panel, "root/aa", cx);
5533 assert_eq!(
5534 visible_entries_as_strings(&panel, 0..10, cx),
5535 &[
5536 "v root",
5537 " .gitignore",
5538 " aa <== selected",
5539 " cc",
5540 " gg",
5541 " hh"
5542 ],
5543 "Initial state should hide files on .gitignore"
5544 );
5545
5546 submit_deletion(&panel, cx);
5547
5548 assert_eq!(
5549 visible_entries_as_strings(&panel, 0..10, cx),
5550 &[
5551 "v root",
5552 " .gitignore",
5553 " cc <== selected",
5554 " gg",
5555 " hh"
5556 ],
5557 "Should select next entry not on .gitignore"
5558 );
5559
5560 // Test 2: Auto selection with many gitignored files next to the deleted file
5561 submit_deletion(&panel, cx);
5562 assert_eq!(
5563 visible_entries_as_strings(&panel, 0..10, cx),
5564 &[
5565 "v root",
5566 " .gitignore",
5567 " gg <== selected",
5568 " hh"
5569 ],
5570 "Should select next entry not on .gitignore"
5571 );
5572
5573 // Test 3: Auto selection of entry before deleted file
5574 select_path(&panel, "root/hh", cx);
5575 assert_eq!(
5576 visible_entries_as_strings(&panel, 0..10, cx),
5577 &[
5578 "v root",
5579 " .gitignore",
5580 " gg",
5581 " hh <== selected"
5582 ],
5583 "Should select next entry not on .gitignore"
5584 );
5585 submit_deletion(&panel, cx);
5586 assert_eq!(
5587 visible_entries_as_strings(&panel, 0..10, cx),
5588 &["v root", " .gitignore", " gg <== selected"],
5589 "Should select next entry not on .gitignore"
5590 );
5591}
5592
5593#[gpui::test]
5594async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
5595 init_test_with_editor(cx);
5596
5597 let fs = FakeFs::new(cx.executor());
5598 fs.insert_tree(
5599 path!("/root"),
5600 json!({
5601 "dir1": {
5602 "file1": "// Testing",
5603 "file2": "// Testing",
5604 "file3": "// Testing"
5605 },
5606 "aa": "// Testing",
5607 ".gitignore": "file1\nfile3\n",
5608 }),
5609 )
5610 .await;
5611
5612 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5613 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5614 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5615
5616 cx.update(|_, cx| {
5617 let settings = *ProjectPanelSettings::get_global(cx);
5618 ProjectPanelSettings::override_global(
5619 ProjectPanelSettings {
5620 hide_gitignore: true,
5621 ..settings
5622 },
5623 cx,
5624 );
5625 });
5626
5627 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5628 cx.run_until_parked();
5629
5630 // Test 1: Visible items should exclude files on gitignore
5631 toggle_expand_dir(&panel, "root/dir1", cx);
5632 select_path(&panel, "root/dir1/file2", cx);
5633 assert_eq!(
5634 visible_entries_as_strings(&panel, 0..10, cx),
5635 &[
5636 "v root",
5637 " v dir1",
5638 " file2 <== selected",
5639 " .gitignore",
5640 " aa"
5641 ],
5642 "Initial state should hide files on .gitignore"
5643 );
5644 submit_deletion(&panel, cx);
5645
5646 // Test 2: Auto selection should go to the parent
5647 assert_eq!(
5648 visible_entries_as_strings(&panel, 0..10, cx),
5649 &[
5650 "v root",
5651 " v dir1 <== selected",
5652 " .gitignore",
5653 " aa"
5654 ],
5655 "Initial state should hide files on .gitignore"
5656 );
5657}
5658
5659#[gpui::test]
5660async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
5661 init_test_with_editor(cx);
5662
5663 let fs = FakeFs::new(cx.executor());
5664 fs.insert_tree(
5665 "/root",
5666 json!({
5667 "dir1": {
5668 "subdir1": {
5669 "a.txt": "",
5670 "b.txt": ""
5671 },
5672 "file1.txt": "",
5673 },
5674 "dir2": {
5675 "subdir2": {
5676 "c.txt": "",
5677 "d.txt": ""
5678 },
5679 "file2.txt": "",
5680 },
5681 "file3.txt": "",
5682 }),
5683 )
5684 .await;
5685
5686 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5687 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5688 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5689 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5690 cx.run_until_parked();
5691
5692 toggle_expand_dir(&panel, "root/dir1", cx);
5693 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5694 toggle_expand_dir(&panel, "root/dir2", cx);
5695 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5696
5697 // Test Case 1: Select and delete nested directory with parent
5698 cx.simulate_modifiers_change(gpui::Modifiers {
5699 control: true,
5700 ..Default::default()
5701 });
5702 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5703 select_path_with_mark(&panel, "root/dir1", cx);
5704
5705 assert_eq!(
5706 visible_entries_as_strings(&panel, 0..15, cx),
5707 &[
5708 "v root",
5709 " v dir1 <== selected <== marked",
5710 " v subdir1 <== marked",
5711 " a.txt",
5712 " b.txt",
5713 " file1.txt",
5714 " v dir2",
5715 " v subdir2",
5716 " c.txt",
5717 " d.txt",
5718 " file2.txt",
5719 " file3.txt",
5720 ],
5721 "Initial state before deleting nested directory with parent"
5722 );
5723
5724 submit_deletion(&panel, cx);
5725 assert_eq!(
5726 visible_entries_as_strings(&panel, 0..15, cx),
5727 &[
5728 "v root",
5729 " v dir2 <== selected",
5730 " v subdir2",
5731 " c.txt",
5732 " d.txt",
5733 " file2.txt",
5734 " file3.txt",
5735 ],
5736 "Should select next directory after deleting directory with parent"
5737 );
5738
5739 // Test Case 2: Select mixed files and directories across levels
5740 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
5741 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
5742 select_path_with_mark(&panel, "root/file3.txt", cx);
5743
5744 assert_eq!(
5745 visible_entries_as_strings(&panel, 0..15, cx),
5746 &[
5747 "v root",
5748 " v dir2",
5749 " v subdir2",
5750 " c.txt <== marked",
5751 " d.txt",
5752 " file2.txt <== marked",
5753 " file3.txt <== selected <== marked",
5754 ],
5755 "Initial state before deleting"
5756 );
5757
5758 submit_deletion(&panel, cx);
5759 assert_eq!(
5760 visible_entries_as_strings(&panel, 0..15, cx),
5761 &[
5762 "v root",
5763 " v dir2 <== selected",
5764 " v subdir2",
5765 " d.txt",
5766 ],
5767 "Should select sibling directory"
5768 );
5769}
5770
5771#[gpui::test]
5772async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
5773 init_test_with_editor(cx);
5774
5775 let fs = FakeFs::new(cx.executor());
5776 fs.insert_tree(
5777 "/root",
5778 json!({
5779 "dir1": {
5780 "subdir1": {
5781 "a.txt": "",
5782 "b.txt": ""
5783 },
5784 "file1.txt": "",
5785 },
5786 "dir2": {
5787 "subdir2": {
5788 "c.txt": "",
5789 "d.txt": ""
5790 },
5791 "file2.txt": "",
5792 },
5793 "file3.txt": "",
5794 "file4.txt": "",
5795 }),
5796 )
5797 .await;
5798
5799 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5800 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5801 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5802 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5803 cx.run_until_parked();
5804
5805 toggle_expand_dir(&panel, "root/dir1", cx);
5806 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5807 toggle_expand_dir(&panel, "root/dir2", cx);
5808 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5809
5810 // Test Case 1: Select all root files and directories
5811 cx.simulate_modifiers_change(gpui::Modifiers {
5812 control: true,
5813 ..Default::default()
5814 });
5815 select_path_with_mark(&panel, "root/dir1", cx);
5816 select_path_with_mark(&panel, "root/dir2", cx);
5817 select_path_with_mark(&panel, "root/file3.txt", cx);
5818 select_path_with_mark(&panel, "root/file4.txt", cx);
5819 assert_eq!(
5820 visible_entries_as_strings(&panel, 0..20, cx),
5821 &[
5822 "v root",
5823 " v dir1 <== marked",
5824 " v subdir1",
5825 " a.txt",
5826 " b.txt",
5827 " file1.txt",
5828 " v dir2 <== marked",
5829 " v subdir2",
5830 " c.txt",
5831 " d.txt",
5832 " file2.txt",
5833 " file3.txt <== marked",
5834 " file4.txt <== selected <== marked",
5835 ],
5836 "State before deleting all contents"
5837 );
5838
5839 submit_deletion(&panel, cx);
5840 assert_eq!(
5841 visible_entries_as_strings(&panel, 0..20, cx),
5842 &["v root <== selected"],
5843 "Only empty root directory should remain after deleting all contents"
5844 );
5845}
5846
5847#[gpui::test]
5848async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
5849 init_test_with_editor(cx);
5850
5851 let fs = FakeFs::new(cx.executor());
5852 fs.insert_tree(
5853 "/root",
5854 json!({
5855 "dir1": {
5856 "subdir1": {
5857 "file_a.txt": "content a",
5858 "file_b.txt": "content b",
5859 },
5860 "subdir2": {
5861 "file_c.txt": "content c",
5862 },
5863 "file1.txt": "content 1",
5864 },
5865 "dir2": {
5866 "file2.txt": "content 2",
5867 },
5868 }),
5869 )
5870 .await;
5871
5872 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5873 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5874 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5875 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5876 cx.run_until_parked();
5877
5878 toggle_expand_dir(&panel, "root/dir1", cx);
5879 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5880 toggle_expand_dir(&panel, "root/dir2", cx);
5881 cx.simulate_modifiers_change(gpui::Modifiers {
5882 control: true,
5883 ..Default::default()
5884 });
5885
5886 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
5887 select_path_with_mark(&panel, "root/dir1", cx);
5888 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5889 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
5890
5891 assert_eq!(
5892 visible_entries_as_strings(&panel, 0..20, cx),
5893 &[
5894 "v root",
5895 " v dir1 <== marked",
5896 " v subdir1 <== marked",
5897 " file_a.txt <== selected <== marked",
5898 " file_b.txt",
5899 " > subdir2",
5900 " file1.txt",
5901 " v dir2",
5902 " file2.txt",
5903 ],
5904 "State with parent dir, subdir, and file selected"
5905 );
5906 submit_deletion(&panel, cx);
5907 assert_eq!(
5908 visible_entries_as_strings(&panel, 0..20, cx),
5909 &["v root", " v dir2 <== selected", " file2.txt",],
5910 "Only dir2 should remain after deletion"
5911 );
5912}
5913
5914#[gpui::test]
5915async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
5916 init_test_with_editor(cx);
5917
5918 let fs = FakeFs::new(cx.executor());
5919 // First worktree
5920 fs.insert_tree(
5921 "/root1",
5922 json!({
5923 "dir1": {
5924 "file1.txt": "content 1",
5925 "file2.txt": "content 2",
5926 },
5927 "dir2": {
5928 "file3.txt": "content 3",
5929 },
5930 }),
5931 )
5932 .await;
5933
5934 // Second worktree
5935 fs.insert_tree(
5936 "/root2",
5937 json!({
5938 "dir3": {
5939 "file4.txt": "content 4",
5940 "file5.txt": "content 5",
5941 },
5942 "file6.txt": "content 6",
5943 }),
5944 )
5945 .await;
5946
5947 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5948 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5949 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5950 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5951 cx.run_until_parked();
5952
5953 // Expand all directories for testing
5954 toggle_expand_dir(&panel, "root1/dir1", cx);
5955 toggle_expand_dir(&panel, "root1/dir2", cx);
5956 toggle_expand_dir(&panel, "root2/dir3", cx);
5957
5958 // Test Case 1: Delete files across different worktrees
5959 cx.simulate_modifiers_change(gpui::Modifiers {
5960 control: true,
5961 ..Default::default()
5962 });
5963 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
5964 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
5965
5966 assert_eq!(
5967 visible_entries_as_strings(&panel, 0..20, cx),
5968 &[
5969 "v root1",
5970 " v dir1",
5971 " file1.txt <== marked",
5972 " file2.txt",
5973 " v dir2",
5974 " file3.txt",
5975 "v root2",
5976 " v dir3",
5977 " file4.txt <== selected <== marked",
5978 " file5.txt",
5979 " file6.txt",
5980 ],
5981 "Initial state with files selected from different worktrees"
5982 );
5983
5984 submit_deletion(&panel, cx);
5985 assert_eq!(
5986 visible_entries_as_strings(&panel, 0..20, cx),
5987 &[
5988 "v root1",
5989 " v dir1",
5990 " file2.txt",
5991 " v dir2",
5992 " file3.txt",
5993 "v root2",
5994 " v dir3",
5995 " file5.txt <== selected",
5996 " file6.txt",
5997 ],
5998 "Should select next file in the last worktree after deletion"
5999 );
6000
6001 // Test Case 2: Delete directories from different worktrees
6002 select_path_with_mark(&panel, "root1/dir1", cx);
6003 select_path_with_mark(&panel, "root2/dir3", cx);
6004
6005 assert_eq!(
6006 visible_entries_as_strings(&panel, 0..20, cx),
6007 &[
6008 "v root1",
6009 " v dir1 <== marked",
6010 " file2.txt",
6011 " v dir2",
6012 " file3.txt",
6013 "v root2",
6014 " v dir3 <== selected <== marked",
6015 " file5.txt",
6016 " file6.txt",
6017 ],
6018 "State with directories marked from different worktrees"
6019 );
6020
6021 submit_deletion(&panel, cx);
6022 assert_eq!(
6023 visible_entries_as_strings(&panel, 0..20, cx),
6024 &[
6025 "v root1",
6026 " v dir2",
6027 " file3.txt",
6028 "v root2",
6029 " file6.txt <== selected",
6030 ],
6031 "Should select remaining file in last worktree after directory deletion"
6032 );
6033
6034 // Test Case 4: Delete all remaining files except roots
6035 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
6036 select_path_with_mark(&panel, "root2/file6.txt", cx);
6037
6038 assert_eq!(
6039 visible_entries_as_strings(&panel, 0..20, cx),
6040 &[
6041 "v root1",
6042 " v dir2",
6043 " file3.txt <== marked",
6044 "v root2",
6045 " file6.txt <== selected <== marked",
6046 ],
6047 "State with all remaining files marked"
6048 );
6049
6050 submit_deletion(&panel, cx);
6051 assert_eq!(
6052 visible_entries_as_strings(&panel, 0..20, cx),
6053 &["v root1", " v dir2", "v root2 <== selected"],
6054 "Second parent root should be selected after deleting"
6055 );
6056}
6057
6058#[gpui::test]
6059async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
6060 init_test_with_editor(cx);
6061
6062 let fs = FakeFs::new(cx.executor());
6063 fs.insert_tree(
6064 "/root",
6065 json!({
6066 "dir1": {
6067 "file1.txt": "",
6068 "file2.txt": "",
6069 "file3.txt": "",
6070 },
6071 "dir2": {
6072 "file4.txt": "",
6073 "file5.txt": "",
6074 },
6075 }),
6076 )
6077 .await;
6078
6079 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6080 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6081 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6082 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6083 cx.run_until_parked();
6084
6085 toggle_expand_dir(&panel, "root/dir1", cx);
6086 toggle_expand_dir(&panel, "root/dir2", cx);
6087
6088 cx.simulate_modifiers_change(gpui::Modifiers {
6089 control: true,
6090 ..Default::default()
6091 });
6092
6093 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6094 select_path(&panel, "root/dir1/file1.txt", cx);
6095
6096 assert_eq!(
6097 visible_entries_as_strings(&panel, 0..15, cx),
6098 &[
6099 "v root",
6100 " v dir1",
6101 " file1.txt <== selected",
6102 " file2.txt <== marked",
6103 " file3.txt",
6104 " v dir2",
6105 " file4.txt",
6106 " file5.txt",
6107 ],
6108 "Initial state with one marked entry and different selection"
6109 );
6110
6111 // Delete should operate on the selected entry (file1.txt)
6112 submit_deletion(&panel, cx);
6113 assert_eq!(
6114 visible_entries_as_strings(&panel, 0..15, cx),
6115 &[
6116 "v root",
6117 " v dir1",
6118 " file2.txt <== selected <== marked",
6119 " file3.txt",
6120 " v dir2",
6121 " file4.txt",
6122 " file5.txt",
6123 ],
6124 "Should delete selected file, not marked file"
6125 );
6126
6127 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6128 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6129 select_path(&panel, "root/dir2/file5.txt", cx);
6130
6131 assert_eq!(
6132 visible_entries_as_strings(&panel, 0..15, cx),
6133 &[
6134 "v root",
6135 " v dir1",
6136 " file2.txt <== marked",
6137 " file3.txt <== marked",
6138 " v dir2",
6139 " file4.txt <== marked",
6140 " file5.txt <== selected",
6141 ],
6142 "Initial state with multiple marked entries and different selection"
6143 );
6144
6145 // Delete should operate on all marked entries, ignoring the selection
6146 submit_deletion(&panel, cx);
6147 assert_eq!(
6148 visible_entries_as_strings(&panel, 0..15, cx),
6149 &[
6150 "v root",
6151 " v dir1",
6152 " v dir2",
6153 " file5.txt <== selected",
6154 ],
6155 "Should delete all marked files, leaving only the selected file"
6156 );
6157}
6158
6159#[gpui::test]
6160async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6161 init_test_with_editor(cx);
6162
6163 let fs = FakeFs::new(cx.executor());
6164 fs.insert_tree(
6165 "/root_b",
6166 json!({
6167 "dir1": {
6168 "file1.txt": "content 1",
6169 "file2.txt": "content 2",
6170 },
6171 }),
6172 )
6173 .await;
6174
6175 fs.insert_tree(
6176 "/root_c",
6177 json!({
6178 "dir2": {},
6179 }),
6180 )
6181 .await;
6182
6183 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6184 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6185 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6186 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6187 cx.run_until_parked();
6188
6189 toggle_expand_dir(&panel, "root_b/dir1", cx);
6190 toggle_expand_dir(&panel, "root_c/dir2", cx);
6191
6192 cx.simulate_modifiers_change(gpui::Modifiers {
6193 control: true,
6194 ..Default::default()
6195 });
6196 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6197 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6198
6199 assert_eq!(
6200 visible_entries_as_strings(&panel, 0..20, cx),
6201 &[
6202 "v root_b",
6203 " v dir1",
6204 " file1.txt <== marked",
6205 " file2.txt <== selected <== marked",
6206 "v root_c",
6207 " v dir2",
6208 ],
6209 "Initial state with files marked in root_b"
6210 );
6211
6212 submit_deletion(&panel, cx);
6213 assert_eq!(
6214 visible_entries_as_strings(&panel, 0..20, cx),
6215 &[
6216 "v root_b",
6217 " v dir1 <== selected",
6218 "v root_c",
6219 " v dir2",
6220 ],
6221 "After deletion in root_b as it's last deletion, selection should be in root_b"
6222 );
6223
6224 select_path_with_mark(&panel, "root_c/dir2", cx);
6225
6226 submit_deletion(&panel, cx);
6227 assert_eq!(
6228 visible_entries_as_strings(&panel, 0..20, cx),
6229 &["v root_b", " v dir1", "v root_c <== selected",],
6230 "After deleting from root_c, it should remain in root_c"
6231 );
6232}
6233
6234fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6235 let path = rel_path(path);
6236 panel.update_in(cx, |panel, window, cx| {
6237 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6238 let worktree = worktree.read(cx);
6239 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6240 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6241 panel.toggle_expanded(entry_id, window, cx);
6242 return;
6243 }
6244 }
6245 panic!("no worktree for path {:?}", path);
6246 });
6247 cx.run_until_parked();
6248}
6249
6250#[gpui::test]
6251async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6252 init_test_with_editor(cx);
6253
6254 let fs = FakeFs::new(cx.executor());
6255 fs.insert_tree(
6256 path!("/root"),
6257 json!({
6258 ".gitignore": "**/ignored_dir\n**/ignored_nested",
6259 "dir1": {
6260 "empty1": {
6261 "empty2": {
6262 "empty3": {
6263 "file.txt": ""
6264 }
6265 }
6266 },
6267 "subdir1": {
6268 "file1.txt": "",
6269 "file2.txt": "",
6270 "ignored_nested": {
6271 "ignored_file.txt": ""
6272 }
6273 },
6274 "ignored_dir": {
6275 "subdir": {
6276 "deep_file.txt": ""
6277 }
6278 }
6279 }
6280 }),
6281 )
6282 .await;
6283
6284 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6285 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6286 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6287
6288 // Test 1: When auto-fold is enabled
6289 cx.update(|_, cx| {
6290 let settings = *ProjectPanelSettings::get_global(cx);
6291 ProjectPanelSettings::override_global(
6292 ProjectPanelSettings {
6293 auto_fold_dirs: true,
6294 ..settings
6295 },
6296 cx,
6297 );
6298 });
6299
6300 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6301 cx.run_until_parked();
6302
6303 assert_eq!(
6304 visible_entries_as_strings(&panel, 0..20, cx),
6305 &["v root", " > dir1", " .gitignore",],
6306 "Initial state should show collapsed root structure"
6307 );
6308
6309 toggle_expand_dir(&panel, "root/dir1", cx);
6310 assert_eq!(
6311 visible_entries_as_strings(&panel, 0..20, cx),
6312 &[
6313 "v root",
6314 " v dir1 <== selected",
6315 " > empty1/empty2/empty3",
6316 " > ignored_dir",
6317 " > subdir1",
6318 " .gitignore",
6319 ],
6320 "Should show first level with auto-folded dirs and ignored dir visible"
6321 );
6322
6323 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6324 panel.update_in(cx, |panel, window, cx| {
6325 let project = panel.project.read(cx);
6326 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6327 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6328 panel.update_visible_entries(None, false, false, window, cx);
6329 });
6330 cx.run_until_parked();
6331
6332 assert_eq!(
6333 visible_entries_as_strings(&panel, 0..20, cx),
6334 &[
6335 "v root",
6336 " v dir1 <== selected",
6337 " v empty1",
6338 " v empty2",
6339 " v empty3",
6340 " file.txt",
6341 " > ignored_dir",
6342 " v subdir1",
6343 " > ignored_nested",
6344 " file1.txt",
6345 " file2.txt",
6346 " .gitignore",
6347 ],
6348 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6349 );
6350
6351 // Test 2: When auto-fold is disabled
6352 cx.update(|_, cx| {
6353 let settings = *ProjectPanelSettings::get_global(cx);
6354 ProjectPanelSettings::override_global(
6355 ProjectPanelSettings {
6356 auto_fold_dirs: false,
6357 ..settings
6358 },
6359 cx,
6360 );
6361 });
6362
6363 panel.update_in(cx, |panel, window, cx| {
6364 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6365 });
6366
6367 toggle_expand_dir(&panel, "root/dir1", cx);
6368 assert_eq!(
6369 visible_entries_as_strings(&panel, 0..20, cx),
6370 &[
6371 "v root",
6372 " v dir1 <== selected",
6373 " > empty1",
6374 " > ignored_dir",
6375 " > subdir1",
6376 " .gitignore",
6377 ],
6378 "With auto-fold disabled: should show all directories separately"
6379 );
6380
6381 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6382 panel.update_in(cx, |panel, window, cx| {
6383 let project = panel.project.read(cx);
6384 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6385 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6386 panel.update_visible_entries(None, false, false, window, cx);
6387 });
6388 cx.run_until_parked();
6389
6390 assert_eq!(
6391 visible_entries_as_strings(&panel, 0..20, cx),
6392 &[
6393 "v root",
6394 " v dir1 <== selected",
6395 " v empty1",
6396 " v empty2",
6397 " v empty3",
6398 " file.txt",
6399 " > ignored_dir",
6400 " v subdir1",
6401 " > ignored_nested",
6402 " file1.txt",
6403 " file2.txt",
6404 " .gitignore",
6405 ],
6406 "After expand_all without auto-fold: should expand all dirs normally, \
6407 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6408 );
6409
6410 // Test 3: When explicitly called on ignored directory
6411 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6412 panel.update_in(cx, |panel, window, cx| {
6413 let project = panel.project.read(cx);
6414 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6415 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6416 panel.update_visible_entries(None, false, false, window, cx);
6417 });
6418 cx.run_until_parked();
6419
6420 assert_eq!(
6421 visible_entries_as_strings(&panel, 0..20, cx),
6422 &[
6423 "v root",
6424 " v dir1 <== selected",
6425 " v empty1",
6426 " v empty2",
6427 " v empty3",
6428 " file.txt",
6429 " v ignored_dir",
6430 " v subdir",
6431 " deep_file.txt",
6432 " v subdir1",
6433 " > ignored_nested",
6434 " file1.txt",
6435 " file2.txt",
6436 " .gitignore",
6437 ],
6438 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
6439 );
6440}
6441
6442#[gpui::test]
6443async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
6444 init_test(cx);
6445
6446 let fs = FakeFs::new(cx.executor());
6447 fs.insert_tree(
6448 path!("/root"),
6449 json!({
6450 "dir1": {
6451 "subdir1": {
6452 "nested1": {
6453 "file1.txt": "",
6454 "file2.txt": ""
6455 },
6456 },
6457 "subdir2": {
6458 "file4.txt": ""
6459 }
6460 },
6461 "dir2": {
6462 "single_file": {
6463 "file5.txt": ""
6464 }
6465 }
6466 }),
6467 )
6468 .await;
6469
6470 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6471 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6472 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6473
6474 // Test 1: Basic collapsing
6475 {
6476 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6477 cx.run_until_parked();
6478
6479 toggle_expand_dir(&panel, "root/dir1", cx);
6480 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6481 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6482 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6483
6484 assert_eq!(
6485 visible_entries_as_strings(&panel, 0..20, cx),
6486 &[
6487 "v root",
6488 " v dir1",
6489 " v subdir1",
6490 " v nested1",
6491 " file1.txt",
6492 " file2.txt",
6493 " v subdir2 <== selected",
6494 " file4.txt",
6495 " > dir2",
6496 ],
6497 "Initial state with everything expanded"
6498 );
6499
6500 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6501 panel.update_in(cx, |panel, window, cx| {
6502 let project = panel.project.read(cx);
6503 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6504 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6505 panel.update_visible_entries(None, false, false, window, cx);
6506 });
6507 cx.run_until_parked();
6508
6509 assert_eq!(
6510 visible_entries_as_strings(&panel, 0..20, cx),
6511 &["v root", " > dir1", " > dir2",],
6512 "All subdirs under dir1 should be collapsed"
6513 );
6514 }
6515
6516 // Test 2: With auto-fold enabled
6517 {
6518 cx.update(|_, cx| {
6519 let settings = *ProjectPanelSettings::get_global(cx);
6520 ProjectPanelSettings::override_global(
6521 ProjectPanelSettings {
6522 auto_fold_dirs: true,
6523 ..settings
6524 },
6525 cx,
6526 );
6527 });
6528
6529 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6530 cx.run_until_parked();
6531
6532 toggle_expand_dir(&panel, "root/dir1", cx);
6533 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6534 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6535
6536 assert_eq!(
6537 visible_entries_as_strings(&panel, 0..20, cx),
6538 &[
6539 "v root",
6540 " v dir1",
6541 " v subdir1/nested1 <== selected",
6542 " file1.txt",
6543 " file2.txt",
6544 " > subdir2",
6545 " > dir2/single_file",
6546 ],
6547 "Initial state with some dirs expanded"
6548 );
6549
6550 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6551 panel.update(cx, |panel, cx| {
6552 let project = panel.project.read(cx);
6553 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6554 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6555 });
6556
6557 toggle_expand_dir(&panel, "root/dir1", cx);
6558
6559 assert_eq!(
6560 visible_entries_as_strings(&panel, 0..20, cx),
6561 &[
6562 "v root",
6563 " v dir1 <== selected",
6564 " > subdir1/nested1",
6565 " > subdir2",
6566 " > dir2/single_file",
6567 ],
6568 "Subdirs should be collapsed and folded with auto-fold enabled"
6569 );
6570 }
6571
6572 // Test 3: With auto-fold disabled
6573 {
6574 cx.update(|_, cx| {
6575 let settings = *ProjectPanelSettings::get_global(cx);
6576 ProjectPanelSettings::override_global(
6577 ProjectPanelSettings {
6578 auto_fold_dirs: false,
6579 ..settings
6580 },
6581 cx,
6582 );
6583 });
6584
6585 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6586 cx.run_until_parked();
6587
6588 toggle_expand_dir(&panel, "root/dir1", cx);
6589 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6590 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6591
6592 assert_eq!(
6593 visible_entries_as_strings(&panel, 0..20, cx),
6594 &[
6595 "v root",
6596 " v dir1",
6597 " v subdir1",
6598 " v nested1 <== selected",
6599 " file1.txt",
6600 " file2.txt",
6601 " > subdir2",
6602 " > dir2",
6603 ],
6604 "Initial state with some dirs expanded and auto-fold disabled"
6605 );
6606
6607 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6608 panel.update(cx, |panel, cx| {
6609 let project = panel.project.read(cx);
6610 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6611 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6612 });
6613
6614 toggle_expand_dir(&panel, "root/dir1", cx);
6615
6616 assert_eq!(
6617 visible_entries_as_strings(&panel, 0..20, cx),
6618 &[
6619 "v root",
6620 " v dir1 <== selected",
6621 " > subdir1",
6622 " > subdir2",
6623 " > dir2",
6624 ],
6625 "Subdirs should be collapsed but not folded with auto-fold disabled"
6626 );
6627 }
6628}
6629
6630#[gpui::test]
6631async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
6632 init_test(cx);
6633
6634 let fs = FakeFs::new(cx.executor());
6635 fs.insert_tree(
6636 path!("/root"),
6637 json!({
6638 "dir1": {
6639 "subdir1": {
6640 "nested1": {
6641 "file1.txt": "",
6642 "file2.txt": ""
6643 },
6644 },
6645 "subdir2": {
6646 "file3.txt": ""
6647 }
6648 },
6649 "dir2": {
6650 "file4.txt": ""
6651 }
6652 }),
6653 )
6654 .await;
6655
6656 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6657 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6658 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6659
6660 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6661 cx.run_until_parked();
6662
6663 toggle_expand_dir(&panel, "root/dir1", cx);
6664 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6665 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6666 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6667 toggle_expand_dir(&panel, "root/dir2", cx);
6668
6669 assert_eq!(
6670 visible_entries_as_strings(&panel, 0..20, cx),
6671 &[
6672 "v root",
6673 " v dir1",
6674 " v subdir1",
6675 " v nested1",
6676 " file1.txt",
6677 " file2.txt",
6678 " v subdir2",
6679 " file3.txt",
6680 " v dir2 <== selected",
6681 " file4.txt",
6682 ],
6683 "Initial state with directories expanded"
6684 );
6685
6686 select_path(&panel, "root/dir1", cx);
6687 cx.run_until_parked();
6688
6689 panel.update_in(cx, |panel, window, cx| {
6690 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6691 });
6692 cx.run_until_parked();
6693
6694 assert_eq!(
6695 visible_entries_as_strings(&panel, 0..20, cx),
6696 &[
6697 "v root",
6698 " > dir1 <== selected",
6699 " v dir2",
6700 " file4.txt",
6701 ],
6702 "dir1 and all its children should be collapsed, dir2 should remain expanded"
6703 );
6704
6705 toggle_expand_dir(&panel, "root/dir1", cx);
6706 cx.run_until_parked();
6707
6708 assert_eq!(
6709 visible_entries_as_strings(&panel, 0..20, cx),
6710 &[
6711 "v root",
6712 " v dir1 <== selected",
6713 " > subdir1",
6714 " > subdir2",
6715 " v dir2",
6716 " file4.txt",
6717 ],
6718 "After re-expanding dir1, its children should still be collapsed"
6719 );
6720}
6721
6722#[gpui::test]
6723async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
6724 init_test(cx);
6725
6726 let fs = FakeFs::new(cx.executor());
6727 fs.insert_tree(
6728 path!("/root"),
6729 json!({
6730 "dir1": {
6731 "subdir1": {
6732 "file1.txt": ""
6733 },
6734 "file2.txt": ""
6735 },
6736 "dir2": {
6737 "file3.txt": ""
6738 }
6739 }),
6740 )
6741 .await;
6742
6743 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6744 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6745 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6746
6747 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6748 cx.run_until_parked();
6749
6750 toggle_expand_dir(&panel, "root/dir1", cx);
6751 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6752 toggle_expand_dir(&panel, "root/dir2", cx);
6753
6754 assert_eq!(
6755 visible_entries_as_strings(&panel, 0..20, cx),
6756 &[
6757 "v root",
6758 " v dir1",
6759 " v subdir1",
6760 " file1.txt",
6761 " file2.txt",
6762 " v dir2 <== selected",
6763 " file3.txt",
6764 ],
6765 "Initial state with directories expanded"
6766 );
6767
6768 // Select the root and collapse it and its children
6769 select_path(&panel, "root", cx);
6770 cx.run_until_parked();
6771
6772 panel.update_in(cx, |panel, window, cx| {
6773 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6774 });
6775 cx.run_until_parked();
6776
6777 // The root and all its children should be collapsed
6778 assert_eq!(
6779 visible_entries_as_strings(&panel, 0..20, cx),
6780 &["> root <== selected"],
6781 "Root and all children should be collapsed"
6782 );
6783
6784 // Re-expand root and dir1, verify children were recursively collapsed
6785 toggle_expand_dir(&panel, "root", cx);
6786 toggle_expand_dir(&panel, "root/dir1", cx);
6787 cx.run_until_parked();
6788
6789 assert_eq!(
6790 visible_entries_as_strings(&panel, 0..20, cx),
6791 &[
6792 "v root",
6793 " v dir1 <== selected",
6794 " > subdir1",
6795 " file2.txt",
6796 " > dir2",
6797 ],
6798 "After re-expanding root and dir1, subdir1 should still be collapsed"
6799 );
6800}
6801
6802#[gpui::test]
6803async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
6804 init_test(cx);
6805
6806 let fs = FakeFs::new(cx.executor());
6807 fs.insert_tree(
6808 path!("/root1"),
6809 json!({
6810 "dir1": {
6811 "subdir1": {
6812 "file1.txt": ""
6813 },
6814 "file2.txt": ""
6815 }
6816 }),
6817 )
6818 .await;
6819 fs.insert_tree(
6820 path!("/root2"),
6821 json!({
6822 "dir2": {
6823 "file3.txt": ""
6824 },
6825 "file4.txt": ""
6826 }),
6827 )
6828 .await;
6829
6830 let project = Project::test(
6831 fs.clone(),
6832 [path!("/root1").as_ref(), path!("/root2").as_ref()],
6833 cx,
6834 )
6835 .await;
6836 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6837 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6838
6839 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6840 cx.run_until_parked();
6841
6842 toggle_expand_dir(&panel, "root1/dir1", cx);
6843 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
6844 toggle_expand_dir(&panel, "root2/dir2", cx);
6845
6846 assert_eq!(
6847 visible_entries_as_strings(&panel, 0..20, cx),
6848 &[
6849 "v root1",
6850 " v dir1",
6851 " v subdir1",
6852 " file1.txt",
6853 " file2.txt",
6854 "v root2",
6855 " v dir2 <== selected",
6856 " file3.txt",
6857 " file4.txt",
6858 ],
6859 "Initial state with directories expanded across worktrees"
6860 );
6861
6862 // Select root1 and collapse it and its children.
6863 // In a multi-worktree project, this should only collapse the selected worktree,
6864 // leaving other worktrees unaffected.
6865 select_path(&panel, "root1", cx);
6866 cx.run_until_parked();
6867
6868 panel.update_in(cx, |panel, window, cx| {
6869 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6870 });
6871 cx.run_until_parked();
6872
6873 assert_eq!(
6874 visible_entries_as_strings(&panel, 0..20, cx),
6875 &[
6876 "> root1 <== selected",
6877 "v root2",
6878 " v dir2",
6879 " file3.txt",
6880 " file4.txt",
6881 ],
6882 "Only root1 should be collapsed, root2 should remain expanded"
6883 );
6884
6885 // Re-expand root1 and verify its children were recursively collapsed
6886 toggle_expand_dir(&panel, "root1", cx);
6887
6888 assert_eq!(
6889 visible_entries_as_strings(&panel, 0..20, cx),
6890 &[
6891 "v root1 <== selected",
6892 " > dir1",
6893 "v root2",
6894 " v dir2",
6895 " file3.txt",
6896 " file4.txt",
6897 ],
6898 "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
6899 );
6900}
6901
6902#[gpui::test]
6903async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
6904 init_test(cx);
6905
6906 let fs = FakeFs::new(cx.executor());
6907 fs.insert_tree(
6908 path!("/root1"),
6909 json!({
6910 "dir1": {
6911 "subdir1": {
6912 "file1.txt": ""
6913 },
6914 "file2.txt": ""
6915 }
6916 }),
6917 )
6918 .await;
6919 fs.insert_tree(
6920 path!("/root2"),
6921 json!({
6922 "dir2": {
6923 "subdir2": {
6924 "file3.txt": ""
6925 },
6926 "file4.txt": ""
6927 }
6928 }),
6929 )
6930 .await;
6931
6932 let project = Project::test(
6933 fs.clone(),
6934 [path!("/root1").as_ref(), path!("/root2").as_ref()],
6935 cx,
6936 )
6937 .await;
6938 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6939 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6940
6941 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6942 cx.run_until_parked();
6943
6944 toggle_expand_dir(&panel, "root1/dir1", cx);
6945 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
6946 toggle_expand_dir(&panel, "root2/dir2", cx);
6947 toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
6948
6949 assert_eq!(
6950 visible_entries_as_strings(&panel, 0..20, cx),
6951 &[
6952 "v root1",
6953 " v dir1",
6954 " v subdir1",
6955 " file1.txt",
6956 " file2.txt",
6957 "v root2",
6958 " v dir2",
6959 " v subdir2 <== selected",
6960 " file3.txt",
6961 " file4.txt",
6962 ],
6963 "Initial state with directories expanded across worktrees"
6964 );
6965
6966 // Select dir1 in root1 and collapse it
6967 select_path(&panel, "root1/dir1", cx);
6968 cx.run_until_parked();
6969
6970 panel.update_in(cx, |panel, window, cx| {
6971 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6972 });
6973 cx.run_until_parked();
6974
6975 assert_eq!(
6976 visible_entries_as_strings(&panel, 0..20, cx),
6977 &[
6978 "v root1",
6979 " > dir1 <== selected",
6980 "v root2",
6981 " v dir2",
6982 " v subdir2",
6983 " file3.txt",
6984 " file4.txt",
6985 ],
6986 "Only dir1 should be collapsed, root2 should be completely unaffected"
6987 );
6988
6989 // Re-expand dir1 and verify subdir1 was recursively collapsed
6990 toggle_expand_dir(&panel, "root1/dir1", cx);
6991
6992 assert_eq!(
6993 visible_entries_as_strings(&panel, 0..20, cx),
6994 &[
6995 "v root1",
6996 " v dir1 <== selected",
6997 " > subdir1",
6998 " file2.txt",
6999 "v root2",
7000 " v dir2",
7001 " v subdir2",
7002 " file3.txt",
7003 " file4.txt",
7004 ],
7005 "After re-expanding dir1, subdir1 should still be collapsed"
7006 );
7007}
7008
7009#[gpui::test]
7010async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
7011 init_test(cx);
7012
7013 let fs = FakeFs::new(cx.executor());
7014 fs.insert_tree(
7015 path!("/root"),
7016 json!({
7017 "dir1": {
7018 "subdir1": {
7019 "file1.txt": ""
7020 },
7021 "file2.txt": ""
7022 },
7023 "dir2": {
7024 "file3.txt": ""
7025 }
7026 }),
7027 )
7028 .await;
7029
7030 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7031 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7032 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7033
7034 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7035 cx.run_until_parked();
7036
7037 toggle_expand_dir(&panel, "root/dir1", cx);
7038 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7039 toggle_expand_dir(&panel, "root/dir2", cx);
7040
7041 assert_eq!(
7042 visible_entries_as_strings(&panel, 0..20, cx),
7043 &[
7044 "v root",
7045 " v dir1",
7046 " v subdir1",
7047 " file1.txt",
7048 " file2.txt",
7049 " v dir2 <== selected",
7050 " file3.txt",
7051 ],
7052 "Initial state with directories expanded"
7053 );
7054
7055 select_path(&panel, "root", cx);
7056 cx.run_until_parked();
7057
7058 panel.update_in(cx, |panel, window, cx| {
7059 panel.collapse_all_for_root(window, cx);
7060 });
7061 cx.run_until_parked();
7062
7063 assert_eq!(
7064 visible_entries_as_strings(&panel, 0..20, cx),
7065 &["v root <== selected", " > dir1", " > dir2"],
7066 "Root should remain expanded but all children should be collapsed"
7067 );
7068
7069 toggle_expand_dir(&panel, "root/dir1", cx);
7070 cx.run_until_parked();
7071
7072 assert_eq!(
7073 visible_entries_as_strings(&panel, 0..20, cx),
7074 &[
7075 "v root",
7076 " v dir1 <== selected",
7077 " > subdir1",
7078 " file2.txt",
7079 " > dir2",
7080 ],
7081 "After re-expanding dir1, subdir1 should still be collapsed"
7082 );
7083}
7084
7085#[gpui::test]
7086async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7087 init_test(cx);
7088
7089 let fs = FakeFs::new(cx.executor());
7090 fs.insert_tree(
7091 path!("/root1"),
7092 json!({
7093 "dir1": {
7094 "subdir1": {
7095 "file1.txt": ""
7096 },
7097 "file2.txt": ""
7098 }
7099 }),
7100 )
7101 .await;
7102 fs.insert_tree(
7103 path!("/root2"),
7104 json!({
7105 "dir2": {
7106 "file3.txt": ""
7107 },
7108 "file4.txt": ""
7109 }),
7110 )
7111 .await;
7112
7113 let project = Project::test(
7114 fs.clone(),
7115 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7116 cx,
7117 )
7118 .await;
7119 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7120 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7121
7122 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7123 cx.run_until_parked();
7124
7125 toggle_expand_dir(&panel, "root1/dir1", cx);
7126 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7127 toggle_expand_dir(&panel, "root2/dir2", cx);
7128
7129 assert_eq!(
7130 visible_entries_as_strings(&panel, 0..20, cx),
7131 &[
7132 "v root1",
7133 " v dir1",
7134 " v subdir1",
7135 " file1.txt",
7136 " file2.txt",
7137 "v root2",
7138 " v dir2 <== selected",
7139 " file3.txt",
7140 " file4.txt",
7141 ],
7142 "Initial state with directories expanded across worktrees"
7143 );
7144
7145 select_path(&panel, "root1", cx);
7146 cx.run_until_parked();
7147
7148 panel.update_in(cx, |panel, window, cx| {
7149 panel.collapse_all_for_root(window, cx);
7150 });
7151 cx.run_until_parked();
7152
7153 assert_eq!(
7154 visible_entries_as_strings(&panel, 0..20, cx),
7155 &[
7156 "> root1 <== selected",
7157 "v root2",
7158 " v dir2",
7159 " file3.txt",
7160 " file4.txt",
7161 ],
7162 "With multiple worktrees, root1 should collapse completely (including itself)"
7163 );
7164}
7165
7166#[gpui::test]
7167async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
7168 init_test(cx);
7169
7170 let fs = FakeFs::new(cx.executor());
7171 fs.insert_tree(
7172 path!("/root"),
7173 json!({
7174 "dir1": {
7175 "subdir1": {
7176 "file1.txt": ""
7177 },
7178 },
7179 "dir2": {
7180 "file2.txt": ""
7181 }
7182 }),
7183 )
7184 .await;
7185
7186 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7187 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7188 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7189
7190 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7191 cx.run_until_parked();
7192
7193 toggle_expand_dir(&panel, "root/dir1", cx);
7194 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7195 toggle_expand_dir(&panel, "root/dir2", cx);
7196
7197 assert_eq!(
7198 visible_entries_as_strings(&panel, 0..20, cx),
7199 &[
7200 "v root",
7201 " v dir1",
7202 " v subdir1",
7203 " file1.txt",
7204 " v dir2 <== selected",
7205 " file2.txt",
7206 ],
7207 "Initial state with directories expanded"
7208 );
7209
7210 select_path(&panel, "root/dir1", cx);
7211 cx.run_until_parked();
7212
7213 panel.update_in(cx, |panel, window, cx| {
7214 panel.collapse_all_for_root(window, cx);
7215 });
7216 cx.run_until_parked();
7217
7218 assert_eq!(
7219 visible_entries_as_strings(&panel, 0..20, cx),
7220 &[
7221 "v root",
7222 " v dir1 <== selected",
7223 " v subdir1",
7224 " file1.txt",
7225 " v dir2",
7226 " file2.txt",
7227 ],
7228 "collapse_all_for_root should be a no-op when called on a non-root directory"
7229 );
7230}
7231
7232#[gpui::test]
7233async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
7234 init_test(cx);
7235
7236 let fs = FakeFs::new(cx.executor());
7237 fs.insert_tree(
7238 path!("/root"),
7239 json!({
7240 "dir1": {
7241 "file1.txt": "",
7242 },
7243 }),
7244 )
7245 .await;
7246
7247 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7248 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7249 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7250
7251 let panel = workspace
7252 .update(cx, |workspace, window, cx| {
7253 let panel = ProjectPanel::new(workspace, window, cx);
7254 workspace.add_panel(panel.clone(), window, cx);
7255 panel
7256 })
7257 .unwrap();
7258 cx.run_until_parked();
7259
7260 #[rustfmt::skip]
7261 assert_eq!(
7262 visible_entries_as_strings(&panel, 0..20, cx),
7263 &[
7264 "v root",
7265 " > dir1",
7266 ],
7267 "Initial state with nothing selected"
7268 );
7269
7270 panel.update_in(cx, |panel, window, cx| {
7271 panel.new_file(&NewFile, window, cx);
7272 });
7273 cx.run_until_parked();
7274 panel.update_in(cx, |panel, window, cx| {
7275 assert!(panel.filename_editor.read(cx).is_focused(window));
7276 });
7277 panel
7278 .update_in(cx, |panel, window, cx| {
7279 panel.filename_editor.update(cx, |editor, cx| {
7280 editor.set_text("hello_from_no_selections", window, cx)
7281 });
7282 panel.confirm_edit(true, window, cx).unwrap()
7283 })
7284 .await
7285 .unwrap();
7286 cx.run_until_parked();
7287 #[rustfmt::skip]
7288 assert_eq!(
7289 visible_entries_as_strings(&panel, 0..20, cx),
7290 &[
7291 "v root",
7292 " > dir1",
7293 " hello_from_no_selections <== selected <== marked",
7294 ],
7295 "A new file is created under the root directory"
7296 );
7297}
7298
7299#[gpui::test]
7300async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
7301 init_test(cx);
7302
7303 let fs = FakeFs::new(cx.executor());
7304 fs.insert_tree(
7305 path!("/root"),
7306 json!({
7307 "existing_dir": {
7308 "existing_file.txt": "",
7309 },
7310 "existing_file.txt": "",
7311 }),
7312 )
7313 .await;
7314
7315 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7316 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7317 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7318
7319 cx.update(|_, cx| {
7320 let settings = *ProjectPanelSettings::get_global(cx);
7321 ProjectPanelSettings::override_global(
7322 ProjectPanelSettings {
7323 hide_root: true,
7324 ..settings
7325 },
7326 cx,
7327 );
7328 });
7329
7330 let panel = workspace
7331 .update(cx, |workspace, window, cx| {
7332 let panel = ProjectPanel::new(workspace, window, cx);
7333 workspace.add_panel(panel.clone(), window, cx);
7334 panel
7335 })
7336 .unwrap();
7337 cx.run_until_parked();
7338
7339 #[rustfmt::skip]
7340 assert_eq!(
7341 visible_entries_as_strings(&panel, 0..20, cx),
7342 &[
7343 "> existing_dir",
7344 " existing_file.txt",
7345 ],
7346 "Initial state with hide_root=true, root should be hidden and nothing selected"
7347 );
7348
7349 panel.update(cx, |panel, _| {
7350 assert!(
7351 panel.selection.is_none(),
7352 "Should have no selection initially"
7353 );
7354 });
7355
7356 // Test 1: Create new file when no entry is selected
7357 panel.update_in(cx, |panel, window, cx| {
7358 panel.new_file(&NewFile, window, cx);
7359 });
7360 cx.run_until_parked();
7361 panel.update_in(cx, |panel, window, cx| {
7362 assert!(panel.filename_editor.read(cx).is_focused(window));
7363 });
7364 cx.run_until_parked();
7365 #[rustfmt::skip]
7366 assert_eq!(
7367 visible_entries_as_strings(&panel, 0..20, cx),
7368 &[
7369 "> existing_dir",
7370 " [EDITOR: ''] <== selected",
7371 " existing_file.txt",
7372 ],
7373 "Editor should appear at root level when hide_root=true and no selection"
7374 );
7375
7376 let confirm = panel.update_in(cx, |panel, window, cx| {
7377 panel.filename_editor.update(cx, |editor, cx| {
7378 editor.set_text("new_file_at_root.txt", window, cx)
7379 });
7380 panel.confirm_edit(true, window, cx).unwrap()
7381 });
7382 confirm.await.unwrap();
7383 cx.run_until_parked();
7384
7385 #[rustfmt::skip]
7386 assert_eq!(
7387 visible_entries_as_strings(&panel, 0..20, cx),
7388 &[
7389 "> existing_dir",
7390 " existing_file.txt",
7391 " new_file_at_root.txt <== selected <== marked",
7392 ],
7393 "New file should be created at root level and visible without root prefix"
7394 );
7395
7396 assert!(
7397 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
7398 "File should be created in the actual root directory"
7399 );
7400
7401 // Test 2: Create new directory when no entry is selected
7402 panel.update(cx, |panel, _| {
7403 panel.selection = None;
7404 });
7405
7406 panel.update_in(cx, |panel, window, cx| {
7407 panel.new_directory(&NewDirectory, window, cx);
7408 });
7409 cx.run_until_parked();
7410
7411 panel.update_in(cx, |panel, window, cx| {
7412 assert!(panel.filename_editor.read(cx).is_focused(window));
7413 });
7414
7415 #[rustfmt::skip]
7416 assert_eq!(
7417 visible_entries_as_strings(&panel, 0..20, cx),
7418 &[
7419 "> [EDITOR: ''] <== selected",
7420 "> existing_dir",
7421 " existing_file.txt",
7422 " new_file_at_root.txt",
7423 ],
7424 "Directory editor should appear at root level when hide_root=true and no selection"
7425 );
7426
7427 let confirm = panel.update_in(cx, |panel, window, cx| {
7428 panel.filename_editor.update(cx, |editor, cx| {
7429 editor.set_text("new_dir_at_root", window, cx)
7430 });
7431 panel.confirm_edit(true, window, cx).unwrap()
7432 });
7433 confirm.await.unwrap();
7434 cx.run_until_parked();
7435
7436 #[rustfmt::skip]
7437 assert_eq!(
7438 visible_entries_as_strings(&panel, 0..20, cx),
7439 &[
7440 "> existing_dir",
7441 "v new_dir_at_root <== selected",
7442 " existing_file.txt",
7443 " new_file_at_root.txt",
7444 ],
7445 "New directory should be created at root level and visible without root prefix"
7446 );
7447
7448 assert!(
7449 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
7450 "Directory should be created in the actual root directory"
7451 );
7452}
7453
7454#[cfg(windows)]
7455#[gpui::test]
7456async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
7457 init_test(cx);
7458
7459 let fs = FakeFs::new(cx.executor());
7460 fs.insert_tree(
7461 path!("/root"),
7462 json!({
7463 "dir1": {
7464 "file1.txt": "",
7465 },
7466 }),
7467 )
7468 .await;
7469
7470 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7471 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7472 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7473
7474 let panel = workspace
7475 .update(cx, |workspace, window, cx| {
7476 let panel = ProjectPanel::new(workspace, window, cx);
7477 workspace.add_panel(panel.clone(), window, cx);
7478 panel
7479 })
7480 .unwrap();
7481 cx.run_until_parked();
7482
7483 #[rustfmt::skip]
7484 assert_eq!(
7485 visible_entries_as_strings(&panel, 0..20, cx),
7486 &[
7487 "v root",
7488 " > dir1",
7489 ],
7490 "Initial state with nothing selected"
7491 );
7492
7493 panel.update_in(cx, |panel, window, cx| {
7494 panel.new_file(&NewFile, window, cx);
7495 });
7496 cx.run_until_parked();
7497 panel.update_in(cx, |panel, window, cx| {
7498 assert!(panel.filename_editor.read(cx).is_focused(window));
7499 });
7500 panel
7501 .update_in(cx, |panel, window, cx| {
7502 panel
7503 .filename_editor
7504 .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
7505 panel.confirm_edit(true, window, cx).unwrap()
7506 })
7507 .await
7508 .unwrap();
7509 cx.run_until_parked();
7510 #[rustfmt::skip]
7511 assert_eq!(
7512 visible_entries_as_strings(&panel, 0..20, cx),
7513 &[
7514 "v root",
7515 " > dir1",
7516 " foo <== selected <== marked",
7517 ],
7518 "A new file is created under the root directory without the trailing dot"
7519 );
7520}
7521
7522#[gpui::test]
7523async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
7524 init_test(cx);
7525
7526 let fs = FakeFs::new(cx.executor());
7527 fs.insert_tree(
7528 "/root",
7529 json!({
7530 "dir1": {
7531 "file1.txt": "",
7532 "dir2": {
7533 "file2.txt": ""
7534 }
7535 },
7536 "file3.txt": ""
7537 }),
7538 )
7539 .await;
7540
7541 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7542 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7543 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7544 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7545 cx.run_until_parked();
7546
7547 panel.update(cx, |panel, cx| {
7548 let project = panel.project.read(cx);
7549 let worktree = project.visible_worktrees(cx).next().unwrap();
7550 let worktree = worktree.read(cx);
7551
7552 // Test 1: Target is a directory, should highlight the directory itself
7553 let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
7554 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
7555 assert_eq!(
7556 result,
7557 Some(dir_entry.id),
7558 "Should highlight directory itself"
7559 );
7560
7561 // Test 2: Target is nested file, should highlight immediate parent
7562 let nested_file = worktree
7563 .entry_for_path(rel_path("dir1/dir2/file2.txt"))
7564 .unwrap();
7565 let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
7566 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
7567 assert_eq!(
7568 result,
7569 Some(nested_parent.id),
7570 "Should highlight immediate parent"
7571 );
7572
7573 // Test 3: Target is root level file, should highlight root
7574 let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
7575 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
7576 assert_eq!(
7577 result,
7578 Some(worktree.root_entry().unwrap().id),
7579 "Root level file should return None"
7580 );
7581
7582 // Test 4: Target is root itself, should highlight root
7583 let root_entry = worktree.root_entry().unwrap();
7584 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
7585 assert_eq!(
7586 result,
7587 Some(root_entry.id),
7588 "Root level file should return None"
7589 );
7590 });
7591}
7592
7593#[gpui::test]
7594async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
7595 init_test(cx);
7596
7597 let fs = FakeFs::new(cx.executor());
7598 fs.insert_tree(
7599 "/root",
7600 json!({
7601 "parent_dir": {
7602 "child_file.txt": "",
7603 "sibling_file.txt": "",
7604 "child_dir": {
7605 "nested_file.txt": ""
7606 }
7607 },
7608 "other_dir": {
7609 "other_file.txt": ""
7610 }
7611 }),
7612 )
7613 .await;
7614
7615 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7616 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7617 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7618 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7619 cx.run_until_parked();
7620
7621 panel.update(cx, |panel, cx| {
7622 let project = panel.project.read(cx);
7623 let worktree = project.visible_worktrees(cx).next().unwrap();
7624 let worktree_id = worktree.read(cx).id();
7625 let worktree = worktree.read(cx);
7626
7627 let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
7628 let child_file = worktree
7629 .entry_for_path(rel_path("parent_dir/child_file.txt"))
7630 .unwrap();
7631 let sibling_file = worktree
7632 .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
7633 .unwrap();
7634 let child_dir = worktree
7635 .entry_for_path(rel_path("parent_dir/child_dir"))
7636 .unwrap();
7637 let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
7638 let other_file = worktree
7639 .entry_for_path(rel_path("other_dir/other_file.txt"))
7640 .unwrap();
7641
7642 // Test 1: Single item drag, don't highlight parent directory
7643 let dragged_selection = DraggedSelection {
7644 active_selection: SelectedEntry {
7645 worktree_id,
7646 entry_id: child_file.id,
7647 },
7648 marked_selections: Arc::new([SelectedEntry {
7649 worktree_id,
7650 entry_id: child_file.id,
7651 }]),
7652 };
7653 let result =
7654 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7655 assert_eq!(result, None, "Should not highlight parent of dragged item");
7656
7657 // Test 2: Single item drag, don't highlight sibling files
7658 let result = panel.highlight_entry_for_selection_drag(
7659 sibling_file,
7660 worktree,
7661 &dragged_selection,
7662 cx,
7663 );
7664 assert_eq!(result, None, "Should not highlight sibling files");
7665
7666 // Test 3: Single item drag, highlight unrelated directory
7667 let result =
7668 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
7669 assert_eq!(
7670 result,
7671 Some(other_dir.id),
7672 "Should highlight unrelated directory"
7673 );
7674
7675 // Test 4: Single item drag, highlight sibling directory
7676 let result =
7677 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7678 assert_eq!(
7679 result,
7680 Some(child_dir.id),
7681 "Should highlight sibling directory"
7682 );
7683
7684 // Test 5: Multiple items drag, highlight parent directory
7685 let dragged_selection = DraggedSelection {
7686 active_selection: SelectedEntry {
7687 worktree_id,
7688 entry_id: child_file.id,
7689 },
7690 marked_selections: Arc::new([
7691 SelectedEntry {
7692 worktree_id,
7693 entry_id: child_file.id,
7694 },
7695 SelectedEntry {
7696 worktree_id,
7697 entry_id: sibling_file.id,
7698 },
7699 ]),
7700 };
7701 let result =
7702 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7703 assert_eq!(
7704 result,
7705 Some(parent_dir.id),
7706 "Should highlight parent with multiple items"
7707 );
7708
7709 // Test 6: Target is file in different directory, highlight parent
7710 let result =
7711 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
7712 assert_eq!(
7713 result,
7714 Some(other_dir.id),
7715 "Should highlight parent of target file"
7716 );
7717
7718 // Test 7: Target is directory, always highlight
7719 let result =
7720 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7721 assert_eq!(
7722 result,
7723 Some(child_dir.id),
7724 "Should always highlight directories"
7725 );
7726 });
7727}
7728
7729#[gpui::test]
7730async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
7731 init_test(cx);
7732
7733 let fs = FakeFs::new(cx.executor());
7734 fs.insert_tree(
7735 "/root1",
7736 json!({
7737 "src": {
7738 "main.rs": "",
7739 "lib.rs": ""
7740 }
7741 }),
7742 )
7743 .await;
7744 fs.insert_tree(
7745 "/root2",
7746 json!({
7747 "src": {
7748 "main.rs": "",
7749 "test.rs": ""
7750 }
7751 }),
7752 )
7753 .await;
7754
7755 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7756 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7757 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7758 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7759 cx.run_until_parked();
7760
7761 panel.update(cx, |panel, cx| {
7762 let project = panel.project.read(cx);
7763 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7764
7765 let worktree_a = &worktrees[0];
7766 let main_rs_from_a = worktree_a
7767 .read(cx)
7768 .entry_for_path(rel_path("src/main.rs"))
7769 .unwrap();
7770
7771 let worktree_b = &worktrees[1];
7772 let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
7773 let main_rs_from_b = worktree_b
7774 .read(cx)
7775 .entry_for_path(rel_path("src/main.rs"))
7776 .unwrap();
7777
7778 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
7779 let dragged_selection = DraggedSelection {
7780 active_selection: SelectedEntry {
7781 worktree_id: worktree_a.read(cx).id(),
7782 entry_id: main_rs_from_a.id,
7783 },
7784 marked_selections: Arc::new([SelectedEntry {
7785 worktree_id: worktree_a.read(cx).id(),
7786 entry_id: main_rs_from_a.id,
7787 }]),
7788 };
7789
7790 let result = panel.highlight_entry_for_selection_drag(
7791 src_dir_from_b,
7792 worktree_b.read(cx),
7793 &dragged_selection,
7794 cx,
7795 );
7796 assert_eq!(
7797 result,
7798 Some(src_dir_from_b.id),
7799 "Should highlight target directory from different worktree even with same relative path"
7800 );
7801
7802 // Test dragging file from worktree A onto file with same relative path in worktree B
7803 let result = panel.highlight_entry_for_selection_drag(
7804 main_rs_from_b,
7805 worktree_b.read(cx),
7806 &dragged_selection,
7807 cx,
7808 );
7809 assert_eq!(
7810 result,
7811 Some(src_dir_from_b.id),
7812 "Should highlight parent of target file from different worktree"
7813 );
7814 });
7815}
7816
7817#[gpui::test]
7818async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
7819 init_test(cx);
7820
7821 let fs = FakeFs::new(cx.executor());
7822 fs.insert_tree(
7823 "/root1",
7824 json!({
7825 "parent_dir": {
7826 "child_file.txt": "",
7827 "nested_dir": {
7828 "nested_file.txt": ""
7829 }
7830 },
7831 "root_file.txt": ""
7832 }),
7833 )
7834 .await;
7835
7836 fs.insert_tree(
7837 "/root2",
7838 json!({
7839 "other_dir": {
7840 "other_file.txt": ""
7841 }
7842 }),
7843 )
7844 .await;
7845
7846 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7847 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7848 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7849 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7850 cx.run_until_parked();
7851
7852 panel.update(cx, |panel, cx| {
7853 let project = panel.project.read(cx);
7854 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7855 let worktree1 = worktrees[0].read(cx);
7856 let worktree2 = worktrees[1].read(cx);
7857 let worktree1_id = worktree1.id();
7858 let _worktree2_id = worktree2.id();
7859
7860 let root1_entry = worktree1.root_entry().unwrap();
7861 let root2_entry = worktree2.root_entry().unwrap();
7862 let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
7863 let child_file = worktree1
7864 .entry_for_path(rel_path("parent_dir/child_file.txt"))
7865 .unwrap();
7866 let nested_file = worktree1
7867 .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
7868 .unwrap();
7869 let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
7870
7871 // Test 1: Multiple entries - should always highlight background
7872 let multiple_dragged_selection = DraggedSelection {
7873 active_selection: SelectedEntry {
7874 worktree_id: worktree1_id,
7875 entry_id: child_file.id,
7876 },
7877 marked_selections: Arc::new([
7878 SelectedEntry {
7879 worktree_id: worktree1_id,
7880 entry_id: child_file.id,
7881 },
7882 SelectedEntry {
7883 worktree_id: worktree1_id,
7884 entry_id: nested_file.id,
7885 },
7886 ]),
7887 };
7888
7889 let result = panel.should_highlight_background_for_selection_drag(
7890 &multiple_dragged_selection,
7891 root1_entry.id,
7892 cx,
7893 );
7894 assert!(result, "Should highlight background for multiple entries");
7895
7896 // Test 2: Single entry with non-empty parent path - should highlight background
7897 let nested_dragged_selection = DraggedSelection {
7898 active_selection: SelectedEntry {
7899 worktree_id: worktree1_id,
7900 entry_id: nested_file.id,
7901 },
7902 marked_selections: Arc::new([SelectedEntry {
7903 worktree_id: worktree1_id,
7904 entry_id: nested_file.id,
7905 }]),
7906 };
7907
7908 let result = panel.should_highlight_background_for_selection_drag(
7909 &nested_dragged_selection,
7910 root1_entry.id,
7911 cx,
7912 );
7913 assert!(result, "Should highlight background for nested file");
7914
7915 // Test 3: Single entry at root level, same worktree - should NOT highlight background
7916 let root_file_dragged_selection = DraggedSelection {
7917 active_selection: SelectedEntry {
7918 worktree_id: worktree1_id,
7919 entry_id: root_file.id,
7920 },
7921 marked_selections: Arc::new([SelectedEntry {
7922 worktree_id: worktree1_id,
7923 entry_id: root_file.id,
7924 }]),
7925 };
7926
7927 let result = panel.should_highlight_background_for_selection_drag(
7928 &root_file_dragged_selection,
7929 root1_entry.id,
7930 cx,
7931 );
7932 assert!(
7933 !result,
7934 "Should NOT highlight background for root file in same worktree"
7935 );
7936
7937 // Test 4: Single entry at root level, different worktree - should highlight background
7938 let result = panel.should_highlight_background_for_selection_drag(
7939 &root_file_dragged_selection,
7940 root2_entry.id,
7941 cx,
7942 );
7943 assert!(
7944 result,
7945 "Should highlight background for root file from different worktree"
7946 );
7947
7948 // Test 5: Single entry in subdirectory - should highlight background
7949 let child_file_dragged_selection = DraggedSelection {
7950 active_selection: SelectedEntry {
7951 worktree_id: worktree1_id,
7952 entry_id: child_file.id,
7953 },
7954 marked_selections: Arc::new([SelectedEntry {
7955 worktree_id: worktree1_id,
7956 entry_id: child_file.id,
7957 }]),
7958 };
7959
7960 let result = panel.should_highlight_background_for_selection_drag(
7961 &child_file_dragged_selection,
7962 root1_entry.id,
7963 cx,
7964 );
7965 assert!(
7966 result,
7967 "Should highlight background for file with non-empty parent path"
7968 );
7969 });
7970}
7971
7972#[gpui::test]
7973async fn test_hide_root(cx: &mut gpui::TestAppContext) {
7974 init_test(cx);
7975
7976 let fs = FakeFs::new(cx.executor());
7977 fs.insert_tree(
7978 "/root1",
7979 json!({
7980 "dir1": {
7981 "file1.txt": "content",
7982 "file2.txt": "content",
7983 },
7984 "dir2": {
7985 "file3.txt": "content",
7986 },
7987 "file4.txt": "content",
7988 }),
7989 )
7990 .await;
7991
7992 fs.insert_tree(
7993 "/root2",
7994 json!({
7995 "dir3": {
7996 "file5.txt": "content",
7997 },
7998 "file6.txt": "content",
7999 }),
8000 )
8001 .await;
8002
8003 // Test 1: Single worktree with hide_root = false
8004 {
8005 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8006 let workspace =
8007 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8008 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8009
8010 cx.update(|_, cx| {
8011 let settings = *ProjectPanelSettings::get_global(cx);
8012 ProjectPanelSettings::override_global(
8013 ProjectPanelSettings {
8014 hide_root: false,
8015 ..settings
8016 },
8017 cx,
8018 );
8019 });
8020
8021 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8022 cx.run_until_parked();
8023
8024 #[rustfmt::skip]
8025 assert_eq!(
8026 visible_entries_as_strings(&panel, 0..10, cx),
8027 &[
8028 "v root1",
8029 " > dir1",
8030 " > dir2",
8031 " file4.txt",
8032 ],
8033 "With hide_root=false and single worktree, root should be visible"
8034 );
8035 }
8036
8037 // Test 2: Single worktree with hide_root = true
8038 {
8039 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8040 let workspace =
8041 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8042 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8043
8044 // Set hide_root to true
8045 cx.update(|_, cx| {
8046 let settings = *ProjectPanelSettings::get_global(cx);
8047 ProjectPanelSettings::override_global(
8048 ProjectPanelSettings {
8049 hide_root: true,
8050 ..settings
8051 },
8052 cx,
8053 );
8054 });
8055
8056 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8057 cx.run_until_parked();
8058
8059 assert_eq!(
8060 visible_entries_as_strings(&panel, 0..10, cx),
8061 &["> dir1", "> dir2", " file4.txt",],
8062 "With hide_root=true and single worktree, root should be hidden"
8063 );
8064
8065 // Test expanding directories still works without root
8066 toggle_expand_dir(&panel, "root1/dir1", cx);
8067 assert_eq!(
8068 visible_entries_as_strings(&panel, 0..10, cx),
8069 &[
8070 "v dir1 <== selected",
8071 " file1.txt",
8072 " file2.txt",
8073 "> dir2",
8074 " file4.txt",
8075 ],
8076 "Should be able to expand directories even when root is hidden"
8077 );
8078 }
8079
8080 // Test 3: Multiple worktrees with hide_root = true
8081 {
8082 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8083 let workspace =
8084 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8085 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8086
8087 // Set hide_root to true
8088 cx.update(|_, cx| {
8089 let settings = *ProjectPanelSettings::get_global(cx);
8090 ProjectPanelSettings::override_global(
8091 ProjectPanelSettings {
8092 hide_root: true,
8093 ..settings
8094 },
8095 cx,
8096 );
8097 });
8098
8099 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8100 cx.run_until_parked();
8101
8102 assert_eq!(
8103 visible_entries_as_strings(&panel, 0..10, cx),
8104 &[
8105 "v root1",
8106 " > dir1",
8107 " > dir2",
8108 " file4.txt",
8109 "v root2",
8110 " > dir3",
8111 " file6.txt",
8112 ],
8113 "With hide_root=true and multiple worktrees, roots should still be visible"
8114 );
8115 }
8116
8117 // Test 4: Multiple worktrees with hide_root = false
8118 {
8119 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8120 let workspace =
8121 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8122 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8123
8124 cx.update(|_, cx| {
8125 let settings = *ProjectPanelSettings::get_global(cx);
8126 ProjectPanelSettings::override_global(
8127 ProjectPanelSettings {
8128 hide_root: false,
8129 ..settings
8130 },
8131 cx,
8132 );
8133 });
8134
8135 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8136 cx.run_until_parked();
8137
8138 assert_eq!(
8139 visible_entries_as_strings(&panel, 0..10, cx),
8140 &[
8141 "v root1",
8142 " > dir1",
8143 " > dir2",
8144 " file4.txt",
8145 "v root2",
8146 " > dir3",
8147 " file6.txt",
8148 ],
8149 "With hide_root=false and multiple worktrees, roots should be visible"
8150 );
8151 }
8152}
8153
8154#[gpui::test]
8155async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
8156 init_test_with_editor(cx);
8157
8158 let fs = FakeFs::new(cx.executor());
8159 fs.insert_tree(
8160 "/root",
8161 json!({
8162 "file1.txt": "content of file1",
8163 "file2.txt": "content of file2",
8164 "dir1": {
8165 "file3.txt": "content of file3"
8166 }
8167 }),
8168 )
8169 .await;
8170
8171 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8172 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8173 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8174 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8175 cx.run_until_parked();
8176
8177 let file1_path = "root/file1.txt";
8178 let file2_path = "root/file2.txt";
8179 select_path_with_mark(&panel, file1_path, cx);
8180 select_path_with_mark(&panel, file2_path, cx);
8181
8182 panel.update_in(cx, |panel, window, cx| {
8183 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
8184 });
8185 cx.executor().run_until_parked();
8186
8187 workspace
8188 .update(cx, |workspace, _, cx| {
8189 let active_items = workspace
8190 .panes()
8191 .iter()
8192 .filter_map(|pane| pane.read(cx).active_item())
8193 .collect::<Vec<_>>();
8194 assert_eq!(active_items.len(), 1);
8195 let diff_view = active_items
8196 .into_iter()
8197 .next()
8198 .unwrap()
8199 .downcast::<FileDiffView>()
8200 .expect("Open item should be an FileDiffView");
8201 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
8202 assert_eq!(
8203 diff_view.tab_tooltip_text(cx).unwrap(),
8204 format!(
8205 "{} ↔ {}",
8206 rel_path(file1_path).display(PathStyle::local()),
8207 rel_path(file2_path).display(PathStyle::local())
8208 )
8209 );
8210 })
8211 .unwrap();
8212
8213 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
8214 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
8215 let worktree_id = panel.update(cx, |panel, cx| {
8216 panel
8217 .project
8218 .read(cx)
8219 .worktrees(cx)
8220 .next()
8221 .unwrap()
8222 .read(cx)
8223 .id()
8224 });
8225
8226 let expected_entries = [
8227 SelectedEntry {
8228 worktree_id,
8229 entry_id: file1_entry_id,
8230 },
8231 SelectedEntry {
8232 worktree_id,
8233 entry_id: file2_entry_id,
8234 },
8235 ];
8236 panel.update(cx, |panel, _cx| {
8237 assert_eq!(
8238 &panel.marked_entries, &expected_entries,
8239 "Should keep marked entries after comparison"
8240 );
8241 });
8242
8243 panel.update(cx, |panel, cx| {
8244 panel.project.update(cx, |_, cx| {
8245 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
8246 })
8247 });
8248
8249 panel.update(cx, |panel, _cx| {
8250 assert_eq!(
8251 &panel.marked_entries, &expected_entries,
8252 "Marked entries should persist after focusing back on the project panel"
8253 );
8254 });
8255}
8256
8257#[gpui::test]
8258async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
8259 init_test_with_editor(cx);
8260
8261 let fs = FakeFs::new(cx.executor());
8262 fs.insert_tree(
8263 "/root",
8264 json!({
8265 "file1.txt": "content of file1",
8266 "file2.txt": "content of file2",
8267 "dir1": {},
8268 "dir2": {
8269 "file3.txt": "content of file3"
8270 }
8271 }),
8272 )
8273 .await;
8274
8275 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8276 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8277 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8278 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8279 cx.run_until_parked();
8280
8281 // Test 1: When only one file is selected, there should be no compare option
8282 select_path(&panel, "root/file1.txt", cx);
8283
8284 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8285 assert_eq!(
8286 selected_files, None,
8287 "Should not have compare option when only one file is selected"
8288 );
8289
8290 // Test 2: When multiple files are selected, there should be a compare option
8291 select_path_with_mark(&panel, "root/file1.txt", cx);
8292 select_path_with_mark(&panel, "root/file2.txt", cx);
8293
8294 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8295 assert!(
8296 selected_files.is_some(),
8297 "Should have files selected for comparison"
8298 );
8299 if let Some((file1, file2)) = selected_files {
8300 assert!(
8301 file1.to_string_lossy().ends_with("file1.txt")
8302 && file2.to_string_lossy().ends_with("file2.txt"),
8303 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
8304 );
8305 }
8306
8307 // Test 3: Selecting a directory shouldn't count as a comparable file
8308 select_path_with_mark(&panel, "root/dir1", cx);
8309
8310 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8311 assert!(
8312 selected_files.is_some(),
8313 "Directory selection should not affect comparable files"
8314 );
8315 if let Some((file1, file2)) = selected_files {
8316 assert!(
8317 file1.to_string_lossy().ends_with("file1.txt")
8318 && file2.to_string_lossy().ends_with("file2.txt"),
8319 "Selecting a directory should not affect the number of comparable files"
8320 );
8321 }
8322
8323 // Test 4: Selecting one more file
8324 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
8325
8326 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8327 assert!(
8328 selected_files.is_some(),
8329 "Directory selection should not affect comparable files"
8330 );
8331 if let Some((file1, file2)) = selected_files {
8332 assert!(
8333 file1.to_string_lossy().ends_with("file2.txt")
8334 && file2.to_string_lossy().ends_with("file3.txt"),
8335 "Selecting a directory should not affect the number of comparable files"
8336 );
8337 }
8338}
8339
8340#[gpui::test]
8341async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
8342 init_test(cx);
8343
8344 let fs = FakeFs::new(cx.executor());
8345 fs.insert_tree(
8346 "/root",
8347 json!({
8348 ".hidden-file.txt": "hidden file content",
8349 "visible-file.txt": "visible file content",
8350 ".hidden-parent-dir": {
8351 "nested-dir": {
8352 "file.txt": "file content",
8353 }
8354 },
8355 "visible-dir": {
8356 "file-in-visible.txt": "file content",
8357 "nested": {
8358 ".hidden-nested-dir": {
8359 ".double-hidden-dir": {
8360 "deep-file-1.txt": "deep content 1",
8361 "deep-file-2.txt": "deep content 2"
8362 },
8363 "hidden-nested-file-1.txt": "hidden nested 1",
8364 "hidden-nested-file-2.txt": "hidden nested 2"
8365 },
8366 "visible-nested-file.txt": "visible nested content"
8367 }
8368 }
8369 }),
8370 )
8371 .await;
8372
8373 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8374 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8375 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8376
8377 cx.update(|_, cx| {
8378 let settings = *ProjectPanelSettings::get_global(cx);
8379 ProjectPanelSettings::override_global(
8380 ProjectPanelSettings {
8381 hide_hidden: false,
8382 ..settings
8383 },
8384 cx,
8385 );
8386 });
8387
8388 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8389 cx.run_until_parked();
8390
8391 toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
8392 toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
8393 toggle_expand_dir(&panel, "root/visible-dir", cx);
8394 toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
8395 toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
8396 toggle_expand_dir(
8397 &panel,
8398 "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
8399 cx,
8400 );
8401
8402 let expanded = [
8403 "v root",
8404 " v .hidden-parent-dir",
8405 " v nested-dir",
8406 " file.txt",
8407 " v visible-dir",
8408 " v nested",
8409 " v .hidden-nested-dir",
8410 " v .double-hidden-dir <== selected",
8411 " deep-file-1.txt",
8412 " deep-file-2.txt",
8413 " hidden-nested-file-1.txt",
8414 " hidden-nested-file-2.txt",
8415 " visible-nested-file.txt",
8416 " file-in-visible.txt",
8417 " .hidden-file.txt",
8418 " visible-file.txt",
8419 ];
8420
8421 assert_eq!(
8422 visible_entries_as_strings(&panel, 0..30, cx),
8423 &expanded,
8424 "With hide_hidden=false, contents of hidden nested directory should be visible"
8425 );
8426
8427 cx.update(|_, cx| {
8428 let settings = *ProjectPanelSettings::get_global(cx);
8429 ProjectPanelSettings::override_global(
8430 ProjectPanelSettings {
8431 hide_hidden: true,
8432 ..settings
8433 },
8434 cx,
8435 );
8436 });
8437
8438 panel.update_in(cx, |panel, window, cx| {
8439 panel.update_visible_entries(None, false, false, window, cx);
8440 });
8441 cx.run_until_parked();
8442
8443 assert_eq!(
8444 visible_entries_as_strings(&panel, 0..30, cx),
8445 &[
8446 "v root",
8447 " v visible-dir",
8448 " v nested",
8449 " visible-nested-file.txt",
8450 " file-in-visible.txt",
8451 " visible-file.txt",
8452 ],
8453 "With hide_hidden=false, contents of hidden nested directory should be visible"
8454 );
8455
8456 panel.update_in(cx, |panel, window, cx| {
8457 let settings = *ProjectPanelSettings::get_global(cx);
8458 ProjectPanelSettings::override_global(
8459 ProjectPanelSettings {
8460 hide_hidden: false,
8461 ..settings
8462 },
8463 cx,
8464 );
8465 panel.update_visible_entries(None, false, false, window, cx);
8466 });
8467 cx.run_until_parked();
8468
8469 assert_eq!(
8470 visible_entries_as_strings(&panel, 0..30, cx),
8471 &expanded,
8472 "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
8473 );
8474}
8475
8476fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8477 let path = rel_path(path);
8478 panel.update_in(cx, |panel, window, cx| {
8479 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8480 let worktree = worktree.read(cx);
8481 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8482 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8483 panel.update_visible_entries(
8484 Some((worktree.id(), entry_id)),
8485 false,
8486 false,
8487 window,
8488 cx,
8489 );
8490 return;
8491 }
8492 }
8493 panic!("no worktree for path {:?}", path);
8494 });
8495 cx.run_until_parked();
8496}
8497
8498fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8499 let path = rel_path(path);
8500 panel.update(cx, |panel, cx| {
8501 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8502 let worktree = worktree.read(cx);
8503 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8504 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8505 let entry = crate::SelectedEntry {
8506 worktree_id: worktree.id(),
8507 entry_id,
8508 };
8509 if !panel.marked_entries.contains(&entry) {
8510 panel.marked_entries.push(entry);
8511 }
8512 panel.selection = Some(entry);
8513 return;
8514 }
8515 }
8516 panic!("no worktree for path {:?}", path);
8517 });
8518}
8519
8520/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
8521/// `active_ancestor_path` is the path to the folded component that should be active.
8522fn select_folded_path_with_mark(
8523 panel: &Entity<ProjectPanel>,
8524 leaf_path: &str,
8525 active_ancestor_path: &str,
8526 cx: &mut VisualTestContext,
8527) {
8528 select_path_with_mark(panel, leaf_path, cx);
8529 set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
8530}
8531
8532fn set_folded_active_ancestor(
8533 panel: &Entity<ProjectPanel>,
8534 leaf_path: &str,
8535 active_ancestor_path: &str,
8536 cx: &mut VisualTestContext,
8537) {
8538 let leaf_path = rel_path(leaf_path);
8539 let active_ancestor_path = rel_path(active_ancestor_path);
8540 panel.update(cx, |panel, cx| {
8541 let mut leaf_entry_id = None;
8542 let mut target_entry_id = None;
8543
8544 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8545 let worktree = worktree.read(cx);
8546 if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
8547 leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8548 }
8549 if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
8550 target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8551 }
8552 }
8553
8554 let leaf_entry_id =
8555 leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
8556 let target_entry_id = target_entry_id
8557 .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
8558 let folded_ancestors = panel
8559 .state
8560 .ancestors
8561 .get_mut(&leaf_entry_id)
8562 .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
8563 let ancestor_ids = folded_ancestors.ancestors.clone();
8564
8565 let mut depth_for_target = None;
8566 for depth in 0..ancestor_ids.len() {
8567 let resolved_entry_id = if depth == 0 {
8568 leaf_entry_id
8569 } else {
8570 ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
8571 };
8572 if resolved_entry_id == target_entry_id {
8573 depth_for_target = Some(depth);
8574 break;
8575 }
8576 }
8577
8578 folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
8579 panic!(
8580 "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
8581 )
8582 });
8583 });
8584}
8585
8586fn drag_selection_to(
8587 panel: &Entity<ProjectPanel>,
8588 target_path: &str,
8589 is_file: bool,
8590 cx: &mut VisualTestContext,
8591) {
8592 let target_entry = find_project_entry(panel, target_path, cx)
8593 .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
8594
8595 panel.update_in(cx, |panel, window, cx| {
8596 let selection = panel
8597 .selection
8598 .expect("a selection is required before dragging");
8599 let drag = DraggedSelection {
8600 active_selection: SelectedEntry {
8601 worktree_id: selection.worktree_id,
8602 entry_id: panel.resolve_entry(selection.entry_id),
8603 },
8604 marked_selections: Arc::from(panel.marked_entries.clone()),
8605 };
8606 panel.drag_onto(&drag, target_entry, is_file, window, cx);
8607 });
8608 cx.executor().run_until_parked();
8609}
8610
8611fn find_project_entry(
8612 panel: &Entity<ProjectPanel>,
8613 path: &str,
8614 cx: &mut VisualTestContext,
8615) -> Option<ProjectEntryId> {
8616 let path = rel_path(path);
8617 panel.update(cx, |panel, cx| {
8618 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8619 let worktree = worktree.read(cx);
8620 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8621 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8622 }
8623 }
8624 panic!("no worktree for path {path:?}");
8625 })
8626}
8627
8628fn visible_entries_as_strings(
8629 panel: &Entity<ProjectPanel>,
8630 range: Range<usize>,
8631 cx: &mut VisualTestContext,
8632) -> Vec<String> {
8633 let mut result = Vec::new();
8634 let mut project_entries = HashSet::default();
8635 let mut has_editor = false;
8636
8637 panel.update_in(cx, |panel, window, cx| {
8638 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
8639 if details.is_editing {
8640 assert!(!has_editor, "duplicate editor entry");
8641 has_editor = true;
8642 } else {
8643 assert!(
8644 project_entries.insert(project_entry),
8645 "duplicate project entry {:?} {:?}",
8646 project_entry,
8647 details
8648 );
8649 }
8650
8651 let indent = " ".repeat(details.depth);
8652 let icon = if details.kind.is_dir() {
8653 if details.is_expanded { "v " } else { "> " }
8654 } else {
8655 " "
8656 };
8657 #[cfg(windows)]
8658 let filename = details.filename.replace("\\", "/");
8659 #[cfg(not(windows))]
8660 let filename = details.filename;
8661 let name = if details.is_editing {
8662 format!("[EDITOR: '{}']", filename)
8663 } else if details.is_processing {
8664 format!("[PROCESSING: '{}']", filename)
8665 } else {
8666 filename
8667 };
8668 let selected = if details.is_selected {
8669 " <== selected"
8670 } else {
8671 ""
8672 };
8673 let marked = if details.is_marked {
8674 " <== marked"
8675 } else {
8676 ""
8677 };
8678
8679 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8680 });
8681 });
8682
8683 result
8684}
8685
8686/// Test that missing sort_mode field defaults to DirectoriesFirst
8687#[gpui::test]
8688async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
8689 init_test(cx);
8690
8691 // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
8692 let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
8693 assert_eq!(
8694 default_settings.sort_mode,
8695 settings::ProjectPanelSortMode::DirectoriesFirst,
8696 "sort_mode should default to DirectoriesFirst"
8697 );
8698}
8699
8700/// Test sort modes: DirectoriesFirst (default) vs Mixed
8701#[gpui::test]
8702async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
8703 init_test(cx);
8704
8705 let fs = FakeFs::new(cx.executor());
8706 fs.insert_tree(
8707 "/root",
8708 json!({
8709 "zebra.txt": "",
8710 "Apple": {},
8711 "banana.rs": "",
8712 "Carrot": {},
8713 "aardvark.txt": "",
8714 }),
8715 )
8716 .await;
8717
8718 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8719 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8720 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8721 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8722 cx.run_until_parked();
8723
8724 // Default sort mode should be DirectoriesFirst
8725 assert_eq!(
8726 visible_entries_as_strings(&panel, 0..50, cx),
8727 &[
8728 "v root",
8729 " > Apple",
8730 " > Carrot",
8731 " aardvark.txt",
8732 " banana.rs",
8733 " zebra.txt",
8734 ]
8735 );
8736}
8737
8738#[gpui::test]
8739async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
8740 init_test(cx);
8741
8742 let fs = FakeFs::new(cx.executor());
8743 fs.insert_tree(
8744 "/root",
8745 json!({
8746 "Zebra.txt": "",
8747 "apple": {},
8748 "Banana.rs": "",
8749 "carrot": {},
8750 "Aardvark.txt": "",
8751 }),
8752 )
8753 .await;
8754
8755 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8756 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8757 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8758
8759 // Switch to Mixed mode
8760 cx.update(|_, cx| {
8761 cx.update_global::<SettingsStore, _>(|store, cx| {
8762 store.update_user_settings(cx, |settings| {
8763 settings.project_panel.get_or_insert_default().sort_mode =
8764 Some(settings::ProjectPanelSortMode::Mixed);
8765 });
8766 });
8767 });
8768
8769 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8770 cx.run_until_parked();
8771
8772 // Mixed mode: case-insensitive sorting
8773 // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
8774 assert_eq!(
8775 visible_entries_as_strings(&panel, 0..50, cx),
8776 &[
8777 "v root",
8778 " Aardvark.txt",
8779 " > apple",
8780 " Banana.rs",
8781 " > carrot",
8782 " Zebra.txt",
8783 ]
8784 );
8785}
8786
8787#[gpui::test]
8788async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
8789 init_test(cx);
8790
8791 let fs = FakeFs::new(cx.executor());
8792 fs.insert_tree(
8793 "/root",
8794 json!({
8795 "Zebra.txt": "",
8796 "apple": {},
8797 "Banana.rs": "",
8798 "carrot": {},
8799 "Aardvark.txt": "",
8800 }),
8801 )
8802 .await;
8803
8804 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8805 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8806 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8807
8808 // Switch to FilesFirst mode
8809 cx.update(|_, cx| {
8810 cx.update_global::<SettingsStore, _>(|store, cx| {
8811 store.update_user_settings(cx, |settings| {
8812 settings.project_panel.get_or_insert_default().sort_mode =
8813 Some(settings::ProjectPanelSortMode::FilesFirst);
8814 });
8815 });
8816 });
8817
8818 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8819 cx.run_until_parked();
8820
8821 // FilesFirst mode: files first, then directories (both case-insensitive)
8822 assert_eq!(
8823 visible_entries_as_strings(&panel, 0..50, cx),
8824 &[
8825 "v root",
8826 " Aardvark.txt",
8827 " Banana.rs",
8828 " Zebra.txt",
8829 " > apple",
8830 " > carrot",
8831 ]
8832 );
8833}
8834
8835#[gpui::test]
8836async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
8837 init_test(cx);
8838
8839 let fs = FakeFs::new(cx.executor());
8840 fs.insert_tree(
8841 "/root",
8842 json!({
8843 "file2.txt": "",
8844 "dir1": {},
8845 "file1.txt": "",
8846 }),
8847 )
8848 .await;
8849
8850 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8851 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8852 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8853 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8854 cx.run_until_parked();
8855
8856 // Initially DirectoriesFirst
8857 assert_eq!(
8858 visible_entries_as_strings(&panel, 0..50, cx),
8859 &["v root", " > dir1", " file1.txt", " file2.txt",]
8860 );
8861
8862 // Toggle to Mixed
8863 cx.update(|_, cx| {
8864 cx.update_global::<SettingsStore, _>(|store, cx| {
8865 store.update_user_settings(cx, |settings| {
8866 settings.project_panel.get_or_insert_default().sort_mode =
8867 Some(settings::ProjectPanelSortMode::Mixed);
8868 });
8869 });
8870 });
8871 cx.run_until_parked();
8872
8873 assert_eq!(
8874 visible_entries_as_strings(&panel, 0..50, cx),
8875 &["v root", " > dir1", " file1.txt", " file2.txt",]
8876 );
8877
8878 // Toggle back to DirectoriesFirst
8879 cx.update(|_, cx| {
8880 cx.update_global::<SettingsStore, _>(|store, cx| {
8881 store.update_user_settings(cx, |settings| {
8882 settings.project_panel.get_or_insert_default().sort_mode =
8883 Some(settings::ProjectPanelSortMode::DirectoriesFirst);
8884 });
8885 });
8886 });
8887 cx.run_until_parked();
8888
8889 assert_eq!(
8890 visible_entries_as_strings(&panel, 0..50, cx),
8891 &["v root", " > dir1", " file1.txt", " file2.txt",]
8892 );
8893}
8894
8895#[gpui::test]
8896async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
8897 cx: &mut gpui::TestAppContext,
8898) {
8899 init_test(cx);
8900
8901 // parent: accept
8902 run_create_file_in_folded_path_case(
8903 "parent",
8904 "root1/parent",
8905 "file_in_parent.txt",
8906 &[
8907 "v root1",
8908 " v parent",
8909 " > subdir/child",
8910 " [EDITOR: ''] <== selected",
8911 ],
8912 &[
8913 "v root1",
8914 " v parent",
8915 " > subdir/child",
8916 " file_in_parent.txt <== selected <== marked",
8917 ],
8918 true,
8919 cx,
8920 )
8921 .await;
8922
8923 // parent: cancel
8924 run_create_file_in_folded_path_case(
8925 "parent",
8926 "root1/parent",
8927 "file_in_parent.txt",
8928 &[
8929 "v root1",
8930 " v parent",
8931 " > subdir/child",
8932 " [EDITOR: ''] <== selected",
8933 ],
8934 &["v root1", " > parent/subdir/child <== selected"],
8935 false,
8936 cx,
8937 )
8938 .await;
8939
8940 // subdir: accept
8941 run_create_file_in_folded_path_case(
8942 "subdir",
8943 "root1/parent/subdir",
8944 "file_in_subdir.txt",
8945 &[
8946 "v root1",
8947 " v parent/subdir",
8948 " > child",
8949 " [EDITOR: ''] <== selected",
8950 ],
8951 &[
8952 "v root1",
8953 " v parent/subdir",
8954 " > child",
8955 " file_in_subdir.txt <== selected <== marked",
8956 ],
8957 true,
8958 cx,
8959 )
8960 .await;
8961
8962 // subdir: cancel
8963 run_create_file_in_folded_path_case(
8964 "subdir",
8965 "root1/parent/subdir",
8966 "file_in_subdir.txt",
8967 &[
8968 "v root1",
8969 " v parent/subdir",
8970 " > child",
8971 " [EDITOR: ''] <== selected",
8972 ],
8973 &["v root1", " > parent/subdir/child <== selected"],
8974 false,
8975 cx,
8976 )
8977 .await;
8978
8979 // child: accept
8980 run_create_file_in_folded_path_case(
8981 "child",
8982 "root1/parent/subdir/child",
8983 "file_in_child.txt",
8984 &[
8985 "v root1",
8986 " v parent/subdir/child",
8987 " [EDITOR: ''] <== selected",
8988 ],
8989 &[
8990 "v root1",
8991 " v parent/subdir/child",
8992 " file_in_child.txt <== selected <== marked",
8993 ],
8994 true,
8995 cx,
8996 )
8997 .await;
8998
8999 // child: cancel
9000 run_create_file_in_folded_path_case(
9001 "child",
9002 "root1/parent/subdir/child",
9003 "file_in_child.txt",
9004 &[
9005 "v root1",
9006 " v parent/subdir/child",
9007 " [EDITOR: ''] <== selected",
9008 ],
9009 &["v root1", " v parent/subdir/child <== selected"],
9010 false,
9011 cx,
9012 )
9013 .await;
9014}
9015
9016#[gpui::test]
9017async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
9018 cx: &mut gpui::TestAppContext,
9019) {
9020 init_test(cx);
9021
9022 let fs = FakeFs::new(cx.executor());
9023 fs.insert_tree(
9024 "/root1",
9025 json!({
9026 "parent": {
9027 "subdir": {
9028 "child": {},
9029 }
9030 }
9031 }),
9032 )
9033 .await;
9034
9035 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9036 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9037 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9038
9039 let panel = workspace
9040 .update(cx, |workspace, window, cx| {
9041 let panel = ProjectPanel::new(workspace, window, cx);
9042 workspace.add_panel(panel.clone(), window, cx);
9043 panel
9044 })
9045 .unwrap();
9046
9047 cx.update(|_, cx| {
9048 let settings = *ProjectPanelSettings::get_global(cx);
9049 ProjectPanelSettings::override_global(
9050 ProjectPanelSettings {
9051 auto_fold_dirs: true,
9052 ..settings
9053 },
9054 cx,
9055 );
9056 });
9057
9058 panel.update_in(cx, |panel, window, cx| {
9059 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9060 });
9061 cx.run_until_parked();
9062
9063 select_folded_path_with_mark(
9064 &panel,
9065 "root1/parent/subdir/child",
9066 "root1/parent/subdir",
9067 cx,
9068 );
9069 panel.update(cx, |panel, _| {
9070 panel.marked_entries.clear();
9071 });
9072
9073 let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
9074 .expect("parent directory should exist for this test");
9075 let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
9076 .expect("subdir directory should exist for this test");
9077 let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
9078 .expect("child directory should exist for this test");
9079
9080 panel.update(cx, |panel, _| {
9081 let selection = panel
9082 .selection
9083 .expect("leaf directory should be selected before creating a new entry");
9084 assert_eq!(
9085 selection.entry_id, child_entry_id,
9086 "initial selection should be the folded leaf entry"
9087 );
9088 assert_eq!(
9089 panel.resolve_entry(selection.entry_id),
9090 subdir_entry_id,
9091 "active folded component should start at subdir"
9092 );
9093 });
9094
9095 panel.update_in(cx, |panel, window, cx| {
9096 panel.deploy_context_menu(
9097 gpui::point(gpui::px(1.), gpui::px(1.)),
9098 child_entry_id,
9099 window,
9100 cx,
9101 );
9102 panel.new_file(&NewFile, window, cx);
9103 });
9104 cx.run_until_parked();
9105 panel.update_in(cx, |panel, window, cx| {
9106 assert!(panel.filename_editor.read(cx).is_focused(window));
9107 });
9108 cx.run_until_parked();
9109
9110 set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
9111
9112 panel.update_in(cx, |panel, window, cx| {
9113 panel.deploy_context_menu(
9114 gpui::point(gpui::px(2.), gpui::px(2.)),
9115 subdir_entry_id,
9116 window,
9117 cx,
9118 );
9119 });
9120 cx.run_until_parked();
9121
9122 panel.update(cx, |panel, _| {
9123 assert!(
9124 panel.state.edit_state.is_none(),
9125 "opening another context menu should blur the filename editor and discard edit state"
9126 );
9127 let selection = panel
9128 .selection
9129 .expect("selection should restore to the previously focused leaf entry");
9130 assert_eq!(
9131 selection.entry_id, child_entry_id,
9132 "blur-driven cancellation should restore the previous leaf selection"
9133 );
9134 assert_eq!(
9135 panel.resolve_entry(selection.entry_id),
9136 parent_entry_id,
9137 "temporary unfolded pending state should preserve the active ancestor chosen before blur"
9138 );
9139 });
9140
9141 panel.update_in(cx, |panel, window, cx| {
9142 panel.new_file(&NewFile, window, cx);
9143 });
9144 cx.run_until_parked();
9145 assert_eq!(
9146 visible_entries_as_strings(&panel, 0..10, cx),
9147 &[
9148 "v root1",
9149 " v parent",
9150 " > subdir/child",
9151 " [EDITOR: ''] <== selected",
9152 ],
9153 "new file after blur should use the preserved active ancestor"
9154 );
9155 panel.update(cx, |panel, _| {
9156 let edit_state = panel
9157 .state
9158 .edit_state
9159 .as_ref()
9160 .expect("new file should enter edit state");
9161 assert_eq!(
9162 edit_state.temporarily_unfolded,
9163 Some(parent_entry_id),
9164 "temporary unfolding should now target parent after restoring the active ancestor"
9165 );
9166 });
9167
9168 let file_name = "created_after_blur.txt";
9169 panel
9170 .update_in(cx, |panel, window, cx| {
9171 panel.filename_editor.update(cx, |editor, cx| {
9172 editor.set_text(file_name, window, cx);
9173 });
9174 panel.confirm_edit(true, window, cx).expect(
9175 "confirm_edit should start creation for the file created after blur transition",
9176 )
9177 })
9178 .await
9179 .expect("creating file after blur transition should succeed");
9180 cx.run_until_parked();
9181
9182 assert!(
9183 fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
9184 .await,
9185 "file should be created under parent after active ancestor is restored to parent"
9186 );
9187 assert!(
9188 !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
9189 .await,
9190 "file should not be created under subdir when parent is the active ancestor"
9191 );
9192}
9193
9194async fn run_create_file_in_folded_path_case(
9195 case_name: &str,
9196 active_ancestor_path: &str,
9197 created_file_name: &str,
9198 expected_temporary_state: &[&str],
9199 expected_final_state: &[&str],
9200 accept_creation: bool,
9201 cx: &mut gpui::TestAppContext,
9202) {
9203 let expected_collapsed_state = &["v root1", " > parent/subdir/child <== selected"];
9204
9205 let fs = FakeFs::new(cx.executor());
9206 fs.insert_tree(
9207 "/root1",
9208 json!({
9209 "parent": {
9210 "subdir": {
9211 "child": {},
9212 }
9213 }
9214 }),
9215 )
9216 .await;
9217
9218 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9219 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9220 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9221
9222 let panel = workspace
9223 .update(cx, |workspace, window, cx| {
9224 let panel = ProjectPanel::new(workspace, window, cx);
9225 workspace.add_panel(panel.clone(), window, cx);
9226 panel
9227 })
9228 .unwrap();
9229
9230 cx.update(|_, cx| {
9231 let settings = *ProjectPanelSettings::get_global(cx);
9232 ProjectPanelSettings::override_global(
9233 ProjectPanelSettings {
9234 auto_fold_dirs: true,
9235 ..settings
9236 },
9237 cx,
9238 );
9239 });
9240
9241 panel.update_in(cx, |panel, window, cx| {
9242 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9243 });
9244 cx.run_until_parked();
9245
9246 select_folded_path_with_mark(
9247 &panel,
9248 "root1/parent/subdir/child",
9249 active_ancestor_path,
9250 cx,
9251 );
9252 panel.update(cx, |panel, _| {
9253 panel.marked_entries.clear();
9254 });
9255
9256 assert_eq!(
9257 visible_entries_as_strings(&panel, 0..10, cx),
9258 expected_collapsed_state,
9259 "case '{}' should start from a folded state",
9260 case_name
9261 );
9262
9263 panel.update_in(cx, |panel, window, cx| {
9264 panel.new_file(&NewFile, window, cx);
9265 });
9266 cx.run_until_parked();
9267 panel.update_in(cx, |panel, window, cx| {
9268 assert!(panel.filename_editor.read(cx).is_focused(window));
9269 });
9270 cx.run_until_parked();
9271 assert_eq!(
9272 visible_entries_as_strings(&panel, 0..10, cx),
9273 expected_temporary_state,
9274 "case '{}' ({}) should temporarily unfold the active ancestor while editing",
9275 case_name,
9276 if accept_creation { "accept" } else { "cancel" }
9277 );
9278
9279 let relative_directory = active_ancestor_path
9280 .strip_prefix("root1/")
9281 .expect("active_ancestor_path should start with root1/");
9282 let created_file_path = PathBuf::from("/root1")
9283 .join(relative_directory)
9284 .join(created_file_name);
9285
9286 if accept_creation {
9287 panel
9288 .update_in(cx, |panel, window, cx| {
9289 panel.filename_editor.update(cx, |editor, cx| {
9290 editor.set_text(created_file_name, window, cx);
9291 });
9292 panel.confirm_edit(true, window, cx).unwrap()
9293 })
9294 .await
9295 .unwrap();
9296 cx.run_until_parked();
9297
9298 assert_eq!(
9299 visible_entries_as_strings(&panel, 0..10, cx),
9300 expected_final_state,
9301 "case '{}' should keep the newly created file selected and marked after accept",
9302 case_name
9303 );
9304 assert!(
9305 fs.is_file(created_file_path.as_path()).await,
9306 "case '{}' should create file '{}'",
9307 case_name,
9308 created_file_path.display()
9309 );
9310 } else {
9311 panel.update_in(cx, |panel, window, cx| {
9312 panel.cancel(&Cancel, window, cx);
9313 });
9314 cx.run_until_parked();
9315
9316 assert_eq!(
9317 visible_entries_as_strings(&panel, 0..10, cx),
9318 expected_final_state,
9319 "case '{}' should keep the expected panel state after cancel",
9320 case_name
9321 );
9322 assert!(
9323 !fs.is_file(created_file_path.as_path()).await,
9324 "case '{}' should not create a file after cancel",
9325 case_name
9326 );
9327 }
9328}
9329
9330fn init_test(cx: &mut TestAppContext) {
9331 cx.update(|cx| {
9332 let settings_store = SettingsStore::test(cx);
9333 cx.set_global(settings_store);
9334 theme::init(theme::LoadThemes::JustBase, cx);
9335 crate::init(cx);
9336
9337 cx.update_global::<SettingsStore, _>(|store, cx| {
9338 store.update_user_settings(cx, |settings| {
9339 settings
9340 .project_panel
9341 .get_or_insert_default()
9342 .auto_fold_dirs = Some(false);
9343 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
9344 });
9345 });
9346 });
9347}
9348
9349fn init_test_with_editor(cx: &mut TestAppContext) {
9350 cx.update(|cx| {
9351 let app_state = AppState::test(cx);
9352 theme::init(theme::LoadThemes::JustBase, cx);
9353 editor::init(cx);
9354 crate::init(cx);
9355 workspace::init(app_state, cx);
9356
9357 cx.update_global::<SettingsStore, _>(|store, cx| {
9358 store.update_user_settings(cx, |settings| {
9359 settings
9360 .project_panel
9361 .get_or_insert_default()
9362 .auto_fold_dirs = Some(false);
9363 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
9364 });
9365 });
9366 });
9367}
9368
9369fn set_auto_open_settings(
9370 cx: &mut TestAppContext,
9371 auto_open_settings: ProjectPanelAutoOpenSettings,
9372) {
9373 cx.update(|cx| {
9374 cx.update_global::<SettingsStore, _>(|store, cx| {
9375 store.update_user_settings(cx, |settings| {
9376 settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
9377 });
9378 })
9379 });
9380}
9381
9382fn ensure_single_file_is_opened(
9383 window: &WindowHandle<Workspace>,
9384 expected_path: &str,
9385 cx: &mut TestAppContext,
9386) {
9387 window
9388 .update(cx, |workspace, _, cx| {
9389 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9390 assert_eq!(worktrees.len(), 1);
9391 let worktree_id = worktrees[0].read(cx).id();
9392
9393 let open_project_paths = workspace
9394 .panes()
9395 .iter()
9396 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9397 .collect::<Vec<_>>();
9398 assert_eq!(
9399 open_project_paths,
9400 vec![ProjectPath {
9401 worktree_id,
9402 path: Arc::from(rel_path(expected_path))
9403 }],
9404 "Should have opened file, selected in project panel"
9405 );
9406 })
9407 .unwrap();
9408}
9409
9410fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9411 assert!(
9412 !cx.has_pending_prompt(),
9413 "Should have no prompts before the deletion"
9414 );
9415 panel.update_in(cx, |panel, window, cx| {
9416 panel.delete(&Delete { skip_prompt: false }, window, cx)
9417 });
9418 assert!(
9419 cx.has_pending_prompt(),
9420 "Should have a prompt after the deletion"
9421 );
9422 cx.simulate_prompt_answer("Delete");
9423 assert!(
9424 !cx.has_pending_prompt(),
9425 "Should have no prompts after prompt was replied to"
9426 );
9427 cx.executor().run_until_parked();
9428}
9429
9430fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9431 assert!(
9432 !cx.has_pending_prompt(),
9433 "Should have no prompts before the deletion"
9434 );
9435 panel.update_in(cx, |panel, window, cx| {
9436 panel.delete(&Delete { skip_prompt: true }, window, cx)
9437 });
9438 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9439 cx.executor().run_until_parked();
9440}
9441
9442fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
9443 assert!(
9444 !cx.has_pending_prompt(),
9445 "Should have no prompts after deletion operation closes the file"
9446 );
9447 workspace
9448 .read_with(cx, |workspace, cx| {
9449 let open_project_paths = workspace
9450 .panes()
9451 .iter()
9452 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9453 .collect::<Vec<_>>();
9454 assert!(
9455 open_project_paths.is_empty(),
9456 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9457 );
9458 })
9459 .unwrap();
9460}
9461
9462struct TestProjectItemView {
9463 focus_handle: FocusHandle,
9464 path: ProjectPath,
9465}
9466
9467struct TestProjectItem {
9468 path: ProjectPath,
9469}
9470
9471impl project::ProjectItem for TestProjectItem {
9472 fn try_open(
9473 _project: &Entity<Project>,
9474 path: &ProjectPath,
9475 cx: &mut App,
9476 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
9477 let path = path.clone();
9478 Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
9479 }
9480
9481 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9482 None
9483 }
9484
9485 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9486 Some(self.path.clone())
9487 }
9488
9489 fn is_dirty(&self) -> bool {
9490 false
9491 }
9492}
9493
9494impl ProjectItem for TestProjectItemView {
9495 type Item = TestProjectItem;
9496
9497 fn for_project_item(
9498 _: Entity<Project>,
9499 _: Option<&Pane>,
9500 project_item: Entity<Self::Item>,
9501 _: &mut Window,
9502 cx: &mut Context<Self>,
9503 ) -> Self
9504 where
9505 Self: Sized,
9506 {
9507 Self {
9508 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9509 focus_handle: cx.focus_handle(),
9510 }
9511 }
9512}
9513
9514impl Item for TestProjectItemView {
9515 type Event = ();
9516
9517 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
9518 "Test".into()
9519 }
9520}
9521
9522impl EventEmitter<()> for TestProjectItemView {}
9523
9524impl Focusable for TestProjectItemView {
9525 fn focus_handle(&self, _: &App) -> FocusHandle {
9526 self.focus_handle.clone()
9527 }
9528}
9529
9530impl Render for TestProjectItemView {
9531 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9532 Empty
9533 }
9534}