1use super::*;
2use collections::HashSet;
3use editor::MultiBufferOffset;
4use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
5use pretty_assertions::assert_eq;
6use project::FakeFs;
7use serde_json::json;
8use settings::{ProjectPanelAutoOpenSettings, SettingsStore};
9use std::path::{Path, PathBuf};
10use util::{path, paths::PathStyle, rel_path::rel_path};
11use workspace::{
12 AppState, ItemHandle, Pane,
13 item::{Item, ProjectItem},
14 register_project_item,
15};
16
17#[gpui::test]
18async fn test_visible_list(cx: &mut gpui::TestAppContext) {
19 init_test(cx);
20
21 let fs = FakeFs::new(cx.executor());
22 fs.insert_tree(
23 "/root1",
24 json!({
25 ".dockerignore": "",
26 ".git": {
27 "HEAD": "",
28 },
29 "a": {
30 "0": { "q": "", "r": "", "s": "" },
31 "1": { "t": "", "u": "" },
32 "2": { "v": "", "w": "", "x": "", "y": "" },
33 },
34 "b": {
35 "3": { "Q": "" },
36 "4": { "R": "", "S": "", "T": "", "U": "" },
37 },
38 "C": {
39 "5": {},
40 "6": { "V": "", "W": "" },
41 "7": { "X": "" },
42 "8": { "Y": {}, "Z": "" }
43 }
44 }),
45 )
46 .await;
47 fs.insert_tree(
48 "/root2",
49 json!({
50 "d": {
51 "9": ""
52 },
53 "e": {}
54 }),
55 )
56 .await;
57
58 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
59 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
60 let cx = &mut VisualTestContext::from_window(*workspace, cx);
61 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
62 cx.run_until_parked();
63 assert_eq!(
64 visible_entries_as_strings(&panel, 0..50, cx),
65 &[
66 "v root1",
67 " > .git",
68 " > a",
69 " > b",
70 " > C",
71 " .dockerignore",
72 "v root2",
73 " > d",
74 " > e",
75 ]
76 );
77
78 toggle_expand_dir(&panel, "root1/b", cx);
79 assert_eq!(
80 visible_entries_as_strings(&panel, 0..50, cx),
81 &[
82 "v root1",
83 " > .git",
84 " > a",
85 " v b <== selected",
86 " > 3",
87 " > 4",
88 " > C",
89 " .dockerignore",
90 "v root2",
91 " > d",
92 " > e",
93 ]
94 );
95
96 assert_eq!(
97 visible_entries_as_strings(&panel, 6..9, cx),
98 &[
99 //
100 " > C",
101 " .dockerignore",
102 "v root2",
103 ]
104 );
105}
106
107#[gpui::test]
108async fn test_opening_file(cx: &mut gpui::TestAppContext) {
109 init_test_with_editor(cx);
110
111 let fs = FakeFs::new(cx.executor());
112 fs.insert_tree(
113 path!("/src"),
114 json!({
115 "test": {
116 "first.rs": "// First Rust file",
117 "second.rs": "// Second Rust file",
118 "third.rs": "// Third Rust file",
119 }
120 }),
121 )
122 .await;
123
124 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
125 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
126 let cx = &mut VisualTestContext::from_window(*workspace, cx);
127 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
128 cx.run_until_parked();
129
130 toggle_expand_dir(&panel, "src/test", cx);
131 select_path(&panel, "src/test/first.rs", cx);
132 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
133 cx.executor().run_until_parked();
134 assert_eq!(
135 visible_entries_as_strings(&panel, 0..10, cx),
136 &[
137 "v src",
138 " v test",
139 " first.rs <== selected <== marked",
140 " second.rs",
141 " third.rs"
142 ]
143 );
144 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
145
146 select_path(&panel, "src/test/second.rs", cx);
147 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
148 cx.executor().run_until_parked();
149 assert_eq!(
150 visible_entries_as_strings(&panel, 0..10, cx),
151 &[
152 "v src",
153 " v test",
154 " first.rs",
155 " second.rs <== selected <== marked",
156 " third.rs"
157 ]
158 );
159 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
160}
161
162#[gpui::test]
163async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
164 init_test(cx);
165 cx.update(|cx| {
166 cx.update_global::<SettingsStore, _>(|store, cx| {
167 store.update_user_settings(cx, |settings| {
168 settings.project.worktree.file_scan_exclusions =
169 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
170 });
171 });
172 });
173
174 let fs = FakeFs::new(cx.background_executor.clone());
175 fs.insert_tree(
176 "/root1",
177 json!({
178 ".dockerignore": "",
179 ".git": {
180 "HEAD": "",
181 },
182 "a": {
183 "0": { "q": "", "r": "", "s": "" },
184 "1": { "t": "", "u": "" },
185 "2": { "v": "", "w": "", "x": "", "y": "" },
186 },
187 "b": {
188 "3": { "Q": "" },
189 "4": { "R": "", "S": "", "T": "", "U": "" },
190 },
191 "C": {
192 "5": {},
193 "6": { "V": "", "W": "" },
194 "7": { "X": "" },
195 "8": { "Y": {}, "Z": "" }
196 }
197 }),
198 )
199 .await;
200 fs.insert_tree(
201 "/root2",
202 json!({
203 "d": {
204 "4": ""
205 },
206 "e": {}
207 }),
208 )
209 .await;
210
211 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
212 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
213 let cx = &mut VisualTestContext::from_window(*workspace, cx);
214 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
215 cx.run_until_parked();
216 assert_eq!(
217 visible_entries_as_strings(&panel, 0..50, cx),
218 &[
219 "v root1",
220 " > a",
221 " > b",
222 " > C",
223 " .dockerignore",
224 "v root2",
225 " > d",
226 " > e",
227 ]
228 );
229
230 toggle_expand_dir(&panel, "root1/b", cx);
231 assert_eq!(
232 visible_entries_as_strings(&panel, 0..50, cx),
233 &[
234 "v root1",
235 " > a",
236 " v b <== selected",
237 " > 3",
238 " > C",
239 " .dockerignore",
240 "v root2",
241 " > d",
242 " > e",
243 ]
244 );
245
246 toggle_expand_dir(&panel, "root2/d", cx);
247 assert_eq!(
248 visible_entries_as_strings(&panel, 0..50, cx),
249 &[
250 "v root1",
251 " > a",
252 " v b",
253 " > 3",
254 " > C",
255 " .dockerignore",
256 "v root2",
257 " v d <== selected",
258 " > e",
259 ]
260 );
261
262 toggle_expand_dir(&panel, "root2/e", cx);
263 assert_eq!(
264 visible_entries_as_strings(&panel, 0..50, cx),
265 &[
266 "v root1",
267 " > a",
268 " v b",
269 " > 3",
270 " > C",
271 " .dockerignore",
272 "v root2",
273 " v d",
274 " v e <== selected",
275 ]
276 );
277}
278
279#[gpui::test]
280async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
281 init_test(cx);
282
283 let fs = FakeFs::new(cx.executor());
284 fs.insert_tree(
285 path!("/root1"),
286 json!({
287 "dir_1": {
288 "nested_dir_1": {
289 "nested_dir_2": {
290 "nested_dir_3": {
291 "file_a.java": "// File contents",
292 "file_b.java": "// File contents",
293 "file_c.java": "// File contents",
294 "nested_dir_4": {
295 "nested_dir_5": {
296 "file_d.java": "// File contents",
297 }
298 }
299 }
300 }
301 }
302 }
303 }),
304 )
305 .await;
306 fs.insert_tree(
307 path!("/root2"),
308 json!({
309 "dir_2": {
310 "file_1.java": "// File contents",
311 }
312 }),
313 )
314 .await;
315
316 // Test 1: Multiple worktrees with auto_fold_dirs = true
317 let project = Project::test(
318 fs.clone(),
319 [path!("/root1").as_ref(), path!("/root2").as_ref()],
320 cx,
321 )
322 .await;
323 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
324 let cx = &mut VisualTestContext::from_window(*workspace, cx);
325 cx.update(|_, cx| {
326 let settings = *ProjectPanelSettings::get_global(cx);
327 ProjectPanelSettings::override_global(
328 ProjectPanelSettings {
329 auto_fold_dirs: true,
330 sort_mode: settings::ProjectPanelSortMode::DirectoriesFirst,
331 ..settings
332 },
333 cx,
334 );
335 });
336 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
337 cx.run_until_parked();
338 assert_eq!(
339 visible_entries_as_strings(&panel, 0..10, cx),
340 &[
341 "v root1",
342 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
343 "v root2",
344 " > dir_2",
345 ]
346 );
347
348 toggle_expand_dir(
349 &panel,
350 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
351 cx,
352 );
353 assert_eq!(
354 visible_entries_as_strings(&panel, 0..10, cx),
355 &[
356 "v root1",
357 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
358 " > nested_dir_4/nested_dir_5",
359 " file_a.java",
360 " file_b.java",
361 " file_c.java",
362 "v root2",
363 " > dir_2",
364 ]
365 );
366
367 toggle_expand_dir(
368 &panel,
369 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
370 cx,
371 );
372 assert_eq!(
373 visible_entries_as_strings(&panel, 0..10, cx),
374 &[
375 "v root1",
376 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
377 " v nested_dir_4/nested_dir_5 <== selected",
378 " file_d.java",
379 " file_a.java",
380 " file_b.java",
381 " file_c.java",
382 "v root2",
383 " > dir_2",
384 ]
385 );
386 toggle_expand_dir(&panel, "root2/dir_2", cx);
387 assert_eq!(
388 visible_entries_as_strings(&panel, 0..10, cx),
389 &[
390 "v root1",
391 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
392 " v nested_dir_4/nested_dir_5",
393 " file_d.java",
394 " file_a.java",
395 " file_b.java",
396 " file_c.java",
397 "v root2",
398 " v dir_2 <== selected",
399 " file_1.java",
400 ]
401 );
402
403 // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true
404 {
405 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
406 let workspace =
407 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
408 let cx = &mut VisualTestContext::from_window(*workspace, cx);
409 cx.update(|_, cx| {
410 let settings = *ProjectPanelSettings::get_global(cx);
411 ProjectPanelSettings::override_global(
412 ProjectPanelSettings {
413 auto_fold_dirs: true,
414 hide_root: true,
415 ..settings
416 },
417 cx,
418 );
419 });
420 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
421 cx.run_until_parked();
422 assert_eq!(
423 visible_entries_as_strings(&panel, 0..10, cx),
424 &["> dir_1/nested_dir_1/nested_dir_2/nested_dir_3"],
425 "Single worktree with hide_root=true should hide root and show auto-folded paths"
426 );
427
428 toggle_expand_dir(
429 &panel,
430 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
431 cx,
432 );
433 assert_eq!(
434 visible_entries_as_strings(&panel, 0..10, cx),
435 &[
436 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
437 " > nested_dir_4/nested_dir_5",
438 " file_a.java",
439 " file_b.java",
440 " file_c.java",
441 ],
442 "Expanded auto-folded path with hidden root should show contents without root prefix"
443 );
444
445 toggle_expand_dir(
446 &panel,
447 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
448 cx,
449 );
450 assert_eq!(
451 visible_entries_as_strings(&panel, 0..10, cx),
452 &[
453 "v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
454 " v nested_dir_4/nested_dir_5 <== selected",
455 " file_d.java",
456 " file_a.java",
457 " file_b.java",
458 " file_c.java",
459 ],
460 "Nested expansion with hidden root should maintain proper indentation"
461 );
462 }
463}
464
465#[gpui::test(iterations = 30)]
466async fn test_editing_files(cx: &mut gpui::TestAppContext) {
467 init_test(cx);
468
469 let fs = FakeFs::new(cx.executor());
470 fs.insert_tree(
471 "/root1",
472 json!({
473 ".dockerignore": "",
474 ".git": {
475 "HEAD": "",
476 },
477 "a": {
478 "0": { "q": "", "r": "", "s": "" },
479 "1": { "t": "", "u": "" },
480 "2": { "v": "", "w": "", "x": "", "y": "" },
481 },
482 "b": {
483 "3": { "Q": "" },
484 "4": { "R": "", "S": "", "T": "", "U": "" },
485 },
486 "C": {
487 "5": {},
488 "6": { "V": "", "W": "" },
489 "7": { "X": "" },
490 "8": { "Y": {}, "Z": "" }
491 }
492 }),
493 )
494 .await;
495 fs.insert_tree(
496 "/root2",
497 json!({
498 "d": {
499 "9": ""
500 },
501 "e": {}
502 }),
503 )
504 .await;
505
506 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
507 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
508 let cx = &mut VisualTestContext::from_window(*workspace, cx);
509 let panel = workspace
510 .update(cx, |workspace, window, cx| {
511 let panel = ProjectPanel::new(workspace, window, cx);
512 workspace.add_panel(panel.clone(), window, cx);
513 panel
514 })
515 .unwrap();
516 cx.run_until_parked();
517
518 select_path(&panel, "root1", cx);
519 assert_eq!(
520 visible_entries_as_strings(&panel, 0..10, cx),
521 &[
522 "v root1 <== selected",
523 " > .git",
524 " > a",
525 " > b",
526 " > C",
527 " .dockerignore",
528 "v root2",
529 " > d",
530 " > e",
531 ]
532 );
533
534 // Add a file with the root folder selected. The filename editor is placed
535 // before the first file in the root folder.
536 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
537 cx.run_until_parked();
538 panel.update_in(cx, |panel, window, cx| {
539 assert!(panel.filename_editor.read(cx).is_focused(window));
540 });
541 assert_eq!(
542 visible_entries_as_strings(&panel, 0..10, cx),
543 &[
544 "v root1",
545 " > .git",
546 " > a",
547 " > b",
548 " > C",
549 " [EDITOR: ''] <== selected",
550 " .dockerignore",
551 "v root2",
552 " > d",
553 " > e",
554 ]
555 );
556
557 let confirm = panel.update_in(cx, |panel, window, cx| {
558 panel.filename_editor.update(cx, |editor, cx| {
559 editor.set_text("the-new-filename", window, cx)
560 });
561 panel.confirm_edit(true, window, cx).unwrap()
562 });
563 assert_eq!(
564 visible_entries_as_strings(&panel, 0..10, cx),
565 &[
566 "v root1",
567 " > .git",
568 " > a",
569 " > b",
570 " > C",
571 " [PROCESSING: 'the-new-filename'] <== selected",
572 " .dockerignore",
573 "v root2",
574 " > d",
575 " > e",
576 ]
577 );
578
579 confirm.await.unwrap();
580 cx.run_until_parked();
581 assert_eq!(
582 visible_entries_as_strings(&panel, 0..10, cx),
583 &[
584 "v root1",
585 " > .git",
586 " > a",
587 " > b",
588 " > C",
589 " .dockerignore",
590 " the-new-filename <== selected <== marked",
591 "v root2",
592 " > d",
593 " > e",
594 ]
595 );
596
597 select_path(&panel, "root1/b", cx);
598 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
599 cx.run_until_parked();
600 assert_eq!(
601 visible_entries_as_strings(&panel, 0..10, cx),
602 &[
603 "v root1",
604 " > .git",
605 " > a",
606 " v b",
607 " > 3",
608 " > 4",
609 " [EDITOR: ''] <== selected",
610 " > C",
611 " .dockerignore",
612 " the-new-filename",
613 ]
614 );
615
616 panel
617 .update_in(cx, |panel, window, cx| {
618 panel.filename_editor.update(cx, |editor, cx| {
619 editor.set_text("another-filename.txt", window, cx)
620 });
621 panel.confirm_edit(true, window, cx).unwrap()
622 })
623 .await
624 .unwrap();
625 cx.run_until_parked();
626 assert_eq!(
627 visible_entries_as_strings(&panel, 0..10, cx),
628 &[
629 "v root1",
630 " > .git",
631 " > a",
632 " v b",
633 " > 3",
634 " > 4",
635 " another-filename.txt <== selected <== marked",
636 " > C",
637 " .dockerignore",
638 " the-new-filename",
639 ]
640 );
641
642 select_path(&panel, "root1/b/another-filename.txt", cx);
643 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
644 assert_eq!(
645 visible_entries_as_strings(&panel, 0..10, cx),
646 &[
647 "v root1",
648 " > .git",
649 " > a",
650 " v b",
651 " > 3",
652 " > 4",
653 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
654 " > C",
655 " .dockerignore",
656 " the-new-filename",
657 ]
658 );
659
660 let confirm = panel.update_in(cx, |panel, window, cx| {
661 panel.filename_editor.update(cx, |editor, cx| {
662 let file_name_selections = editor
663 .selections
664 .all::<MultiBufferOffset>(&editor.display_snapshot(cx));
665 assert_eq!(
666 file_name_selections.len(),
667 1,
668 "File editing should have a single selection, but got: {file_name_selections:?}"
669 );
670 let file_name_selection = &file_name_selections[0];
671 assert_eq!(
672 file_name_selection.start,
673 MultiBufferOffset(0),
674 "Should select the file name from the start"
675 );
676 assert_eq!(
677 file_name_selection.end,
678 MultiBufferOffset("another-filename".len()),
679 "Should not select file extension"
680 );
681
682 editor.set_text("a-different-filename.tar.gz", window, cx)
683 });
684 panel.confirm_edit(true, window, cx).unwrap()
685 });
686 assert_eq!(
687 visible_entries_as_strings(&panel, 0..10, cx),
688 &[
689 "v root1",
690 " > .git",
691 " > a",
692 " v b",
693 " > 3",
694 " > 4",
695 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
696 " > C",
697 " .dockerignore",
698 " the-new-filename",
699 ]
700 );
701
702 confirm.await.unwrap();
703 cx.run_until_parked();
704 assert_eq!(
705 visible_entries_as_strings(&panel, 0..10, cx),
706 &[
707 "v root1",
708 " > .git",
709 " > a",
710 " v b",
711 " > 3",
712 " > 4",
713 " a-different-filename.tar.gz <== selected",
714 " > C",
715 " .dockerignore",
716 " the-new-filename",
717 ]
718 );
719
720 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
721 assert_eq!(
722 visible_entries_as_strings(&panel, 0..10, cx),
723 &[
724 "v root1",
725 " > .git",
726 " > a",
727 " v b",
728 " > 3",
729 " > 4",
730 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
731 " > C",
732 " .dockerignore",
733 " the-new-filename",
734 ]
735 );
736
737 panel.update_in(cx, |panel, window, cx| {
738 panel.filename_editor.update(cx, |editor, cx| {
739 let file_name_selections = editor.selections.all::<MultiBufferOffset>(&editor.display_snapshot(cx));
740 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
741 let file_name_selection = &file_name_selections[0];
742 assert_eq!(file_name_selection.start, MultiBufferOffset(0), "Should select the file name from the start");
743 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..");
744
745 });
746 panel.cancel(&menu::Cancel, window, cx)
747 });
748 cx.run_until_parked();
749 panel.update_in(cx, |panel, window, cx| {
750 panel.new_directory(&NewDirectory, window, cx)
751 });
752 cx.run_until_parked();
753 assert_eq!(
754 visible_entries_as_strings(&panel, 0..10, cx),
755 &[
756 "v root1",
757 " > .git",
758 " > a",
759 " v b",
760 " > [EDITOR: ''] <== selected",
761 " > 3",
762 " > 4",
763 " a-different-filename.tar.gz",
764 " > C",
765 " .dockerignore",
766 ]
767 );
768
769 let confirm = panel.update_in(cx, |panel, window, cx| {
770 panel
771 .filename_editor
772 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
773 panel.confirm_edit(true, window, cx).unwrap()
774 });
775 panel.update_in(cx, |panel, window, cx| {
776 panel.select_next(&Default::default(), window, cx)
777 });
778 assert_eq!(
779 visible_entries_as_strings(&panel, 0..10, cx),
780 &[
781 "v root1",
782 " > .git",
783 " > a",
784 " v b",
785 " > [PROCESSING: 'new-dir']",
786 " > 3 <== selected",
787 " > 4",
788 " a-different-filename.tar.gz",
789 " > C",
790 " .dockerignore",
791 ]
792 );
793
794 confirm.await.unwrap();
795 cx.run_until_parked();
796 assert_eq!(
797 visible_entries_as_strings(&panel, 0..10, cx),
798 &[
799 "v root1",
800 " > .git",
801 " > a",
802 " v b",
803 " > 3 <== selected",
804 " > 4",
805 " > new-dir",
806 " a-different-filename.tar.gz",
807 " > C",
808 " .dockerignore",
809 ]
810 );
811
812 panel.update_in(cx, |panel, window, cx| {
813 panel.rename(&Default::default(), window, cx)
814 });
815 cx.run_until_parked();
816 assert_eq!(
817 visible_entries_as_strings(&panel, 0..10, cx),
818 &[
819 "v root1",
820 " > .git",
821 " > a",
822 " v b",
823 " > [EDITOR: '3'] <== selected",
824 " > 4",
825 " > new-dir",
826 " a-different-filename.tar.gz",
827 " > C",
828 " .dockerignore",
829 ]
830 );
831
832 // Dismiss the rename editor when it loses focus.
833 workspace.update(cx, |_, window, _| window.blur()).unwrap();
834 assert_eq!(
835 visible_entries_as_strings(&panel, 0..10, cx),
836 &[
837 "v root1",
838 " > .git",
839 " > a",
840 " v b",
841 " > 3 <== selected",
842 " > 4",
843 " > new-dir",
844 " a-different-filename.tar.gz",
845 " > C",
846 " .dockerignore",
847 ]
848 );
849
850 // Test empty filename and filename with only whitespace
851 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
852 cx.run_until_parked();
853 assert_eq!(
854 visible_entries_as_strings(&panel, 0..10, cx),
855 &[
856 "v root1",
857 " > .git",
858 " > a",
859 " v b",
860 " v 3",
861 " [EDITOR: ''] <== selected",
862 " Q",
863 " > 4",
864 " > new-dir",
865 " a-different-filename.tar.gz",
866 ]
867 );
868 panel.update_in(cx, |panel, window, cx| {
869 panel.filename_editor.update(cx, |editor, cx| {
870 editor.set_text("", window, cx);
871 });
872 assert!(panel.confirm_edit(true, window, cx).is_none());
873 panel.filename_editor.update(cx, |editor, cx| {
874 editor.set_text(" ", window, cx);
875 });
876 assert!(panel.confirm_edit(true, window, cx).is_none());
877 panel.cancel(&menu::Cancel, window, cx);
878 panel.update_visible_entries(None, false, false, 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 panel.update_visible_entries(None, false, false, window, cx);
2329 });
2330 cx.run_until_parked();
2331 assert_eq!(
2332 visible_entries_as_strings(&panel, 0..10, cx),
2333 &[
2334 //
2335 "v src <== selected",
2336 " > test"
2337 ],
2338 "File list should be unchanged after failed folder create confirmation"
2339 );
2340
2341 select_path(&panel, "src/test", cx);
2342 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2343 cx.executor().run_until_parked();
2344 assert_eq!(
2345 visible_entries_as_strings(&panel, 0..10, cx),
2346 &[
2347 //
2348 "v src",
2349 " > test <== selected"
2350 ]
2351 );
2352 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2353 cx.run_until_parked();
2354 panel.update_in(cx, |panel, window, cx| {
2355 assert!(panel.filename_editor.read(cx).is_focused(window));
2356 });
2357 assert_eq!(
2358 visible_entries_as_strings(&panel, 0..10, cx),
2359 &[
2360 "v src",
2361 " v test",
2362 " [EDITOR: ''] <== selected",
2363 " first.rs",
2364 " second.rs",
2365 " third.rs"
2366 ]
2367 );
2368 panel.update_in(cx, |panel, window, cx| {
2369 panel
2370 .filename_editor
2371 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2372 assert!(
2373 panel.confirm_edit(true, window, cx).is_none(),
2374 "Should not allow to confirm on conflicting new file name"
2375 );
2376 });
2377 cx.executor().run_until_parked();
2378 panel.update_in(cx, |panel, window, cx| {
2379 assert!(
2380 panel.state.edit_state.is_some(),
2381 "Edit state should not be None after conflicting new file name"
2382 );
2383 panel.cancel(&menu::Cancel, window, cx);
2384 panel.update_visible_entries(None, false, false, window, cx);
2385 });
2386 cx.run_until_parked();
2387 assert_eq!(
2388 visible_entries_as_strings(&panel, 0..10, cx),
2389 &[
2390 "v src",
2391 " v test <== selected",
2392 " first.rs",
2393 " second.rs",
2394 " third.rs"
2395 ],
2396 "File list should be unchanged after failed file create confirmation"
2397 );
2398
2399 select_path(&panel, "src/test/first.rs", cx);
2400 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2401 cx.executor().run_until_parked();
2402 assert_eq!(
2403 visible_entries_as_strings(&panel, 0..10, cx),
2404 &[
2405 "v src",
2406 " v test",
2407 " first.rs <== selected",
2408 " second.rs",
2409 " third.rs"
2410 ],
2411 );
2412 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2413 cx.executor().run_until_parked();
2414 panel.update_in(cx, |panel, window, cx| {
2415 assert!(panel.filename_editor.read(cx).is_focused(window));
2416 });
2417 assert_eq!(
2418 visible_entries_as_strings(&panel, 0..10, cx),
2419 &[
2420 "v src",
2421 " v test",
2422 " [EDITOR: 'first.rs'] <== selected",
2423 " second.rs",
2424 " third.rs"
2425 ]
2426 );
2427 panel.update_in(cx, |panel, window, cx| {
2428 panel
2429 .filename_editor
2430 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2431 assert!(
2432 panel.confirm_edit(true, window, cx).is_none(),
2433 "Should not allow to confirm on conflicting file rename"
2434 )
2435 });
2436 cx.executor().run_until_parked();
2437 panel.update_in(cx, |panel, window, cx| {
2438 assert!(
2439 panel.state.edit_state.is_some(),
2440 "Edit state should not be None after conflicting file rename"
2441 );
2442 panel.cancel(&menu::Cancel, window, cx);
2443 });
2444 assert_eq!(
2445 visible_entries_as_strings(&panel, 0..10, cx),
2446 &[
2447 "v src",
2448 " v test",
2449 " first.rs <== selected",
2450 " second.rs",
2451 " third.rs"
2452 ],
2453 "File list should be unchanged after failed rename confirmation"
2454 );
2455}
2456
2457// NOTE: This test is skipped on Windows, because on Windows,
2458// when it triggers the lsp store it converts `/src/test/first copy.txt` into an uri
2459// but it fails with message `"/src\\test\\first copy.txt" is not parseable as an URI`
2460#[gpui::test]
2461#[cfg_attr(target_os = "windows", ignore)]
2462async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppContext) {
2463 init_test_with_editor(cx);
2464
2465 let fs = FakeFs::new(cx.executor());
2466 fs.insert_tree(
2467 "/src",
2468 json!({
2469 "test": {
2470 "first.txt": "// First Txt file",
2471 "second.txt": "// Second Txt file",
2472 "third.txt": "// Third Txt file",
2473 }
2474 }),
2475 )
2476 .await;
2477
2478 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2479 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2480 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2481 let panel = workspace
2482 .update(cx, |workspace, window, cx| {
2483 let panel = ProjectPanel::new(workspace, window, cx);
2484 workspace.add_panel(panel.clone(), window, cx);
2485 panel
2486 })
2487 .unwrap();
2488 cx.run_until_parked();
2489
2490 select_path(&panel, "src", cx);
2491 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2492 cx.executor().run_until_parked();
2493 assert_eq!(
2494 visible_entries_as_strings(&panel, 0..10, cx),
2495 &[
2496 //
2497 "v src <== selected",
2498 " > test"
2499 ]
2500 );
2501 panel.update_in(cx, |panel, window, cx| {
2502 panel.new_directory(&NewDirectory, window, cx)
2503 });
2504 cx.run_until_parked();
2505 panel.update_in(cx, |panel, window, cx| {
2506 assert!(panel.filename_editor.read(cx).is_focused(window));
2507 });
2508 cx.executor().run_until_parked();
2509 assert_eq!(
2510 visible_entries_as_strings(&panel, 0..10, cx),
2511 &[
2512 //
2513 "v src",
2514 " > [EDITOR: ''] <== selected",
2515 " > test"
2516 ]
2517 );
2518 panel.update_in(cx, |panel, window, cx| {
2519 panel
2520 .filename_editor
2521 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2522 assert!(
2523 panel.confirm_edit(true, window, cx).is_none(),
2524 "Should not allow to confirm on conflicting new directory name"
2525 );
2526 });
2527 cx.executor().run_until_parked();
2528 panel.update_in(cx, |panel, window, cx| {
2529 assert!(
2530 panel.state.edit_state.is_some(),
2531 "Edit state should not be None after conflicting new directory name"
2532 );
2533 panel.cancel(&menu::Cancel, window, cx);
2534 panel.update_visible_entries(None, false, false, window, cx);
2535 });
2536 cx.run_until_parked();
2537 assert_eq!(
2538 visible_entries_as_strings(&panel, 0..10, cx),
2539 &[
2540 //
2541 "v src <== selected",
2542 " > test"
2543 ],
2544 "File list should be unchanged after failed folder create confirmation"
2545 );
2546
2547 select_path(&panel, "src/test", cx);
2548 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2549 cx.executor().run_until_parked();
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..10, cx),
2552 &[
2553 //
2554 "v src",
2555 " > test <== selected"
2556 ]
2557 );
2558 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2559 cx.run_until_parked();
2560 panel.update_in(cx, |panel, window, cx| {
2561 assert!(panel.filename_editor.read(cx).is_focused(window));
2562 });
2563 assert_eq!(
2564 visible_entries_as_strings(&panel, 0..10, cx),
2565 &[
2566 "v src",
2567 " v test",
2568 " [EDITOR: ''] <== selected",
2569 " first.txt",
2570 " second.txt",
2571 " third.txt"
2572 ]
2573 );
2574 panel.update_in(cx, |panel, window, cx| {
2575 panel
2576 .filename_editor
2577 .update(cx, |editor, cx| editor.set_text("first.txt", window, cx));
2578 assert!(
2579 panel.confirm_edit(true, window, cx).is_none(),
2580 "Should not allow to confirm on conflicting new file name"
2581 );
2582 });
2583 cx.executor().run_until_parked();
2584 panel.update_in(cx, |panel, window, cx| {
2585 assert!(
2586 panel.state.edit_state.is_some(),
2587 "Edit state should not be None after conflicting new file name"
2588 );
2589 panel.cancel(&menu::Cancel, window, cx);
2590 panel.update_visible_entries(None, false, false, window, cx);
2591 });
2592 cx.run_until_parked();
2593 assert_eq!(
2594 visible_entries_as_strings(&panel, 0..10, cx),
2595 &[
2596 "v src",
2597 " v test <== selected",
2598 " first.txt",
2599 " second.txt",
2600 " third.txt"
2601 ],
2602 "File list should be unchanged after failed file create confirmation"
2603 );
2604
2605 select_path(&panel, "src/test/first.txt", cx);
2606 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2607 cx.executor().run_until_parked();
2608 assert_eq!(
2609 visible_entries_as_strings(&panel, 0..10, cx),
2610 &[
2611 "v src",
2612 " v test",
2613 " first.txt <== selected",
2614 " second.txt",
2615 " third.txt"
2616 ],
2617 );
2618 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2619 cx.executor().run_until_parked();
2620 panel.update_in(cx, |panel, window, cx| {
2621 assert!(panel.filename_editor.read(cx).is_focused(window));
2622 });
2623 assert_eq!(
2624 visible_entries_as_strings(&panel, 0..10, cx),
2625 &[
2626 "v src",
2627 " v test",
2628 " [EDITOR: 'first.txt'] <== selected",
2629 " second.txt",
2630 " third.txt"
2631 ]
2632 );
2633 panel.update_in(cx, |panel, window, cx| {
2634 panel
2635 .filename_editor
2636 .update(cx, |editor, cx| editor.set_text("second.txt", window, cx));
2637 assert!(
2638 panel.confirm_edit(true, window, cx).is_none(),
2639 "Should not allow to confirm on conflicting file rename"
2640 )
2641 });
2642 cx.executor().run_until_parked();
2643 panel.update_in(cx, |panel, window, cx| {
2644 assert!(
2645 panel.state.edit_state.is_some(),
2646 "Edit state should not be None after conflicting file rename"
2647 );
2648 panel.cancel(&menu::Cancel, window, cx);
2649 });
2650 assert_eq!(
2651 visible_entries_as_strings(&panel, 0..10, cx),
2652 &[
2653 "v src",
2654 " v test",
2655 " first.txt <== selected",
2656 " second.txt",
2657 " third.txt"
2658 ],
2659 "File list should be unchanged after failed rename confirmation"
2660 );
2661 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2662 cx.executor().run_until_parked();
2663 // Try to duplicate and check history
2664 panel.update_in(cx, |panel, window, cx| {
2665 panel.duplicate(&Duplicate, window, cx)
2666 });
2667 cx.executor().run_until_parked();
2668
2669 assert_eq!(
2670 visible_entries_as_strings(&panel, 0..10, cx),
2671 &[
2672 "v src",
2673 " v test",
2674 " first.txt",
2675 " [EDITOR: 'first copy.txt'] <== selected <== marked",
2676 " second.txt",
2677 " third.txt"
2678 ],
2679 );
2680
2681 let confirm = panel.update_in(cx, |panel, window, cx| {
2682 panel
2683 .filename_editor
2684 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2685 panel.confirm_edit(true, window, cx).unwrap()
2686 });
2687 confirm.await.unwrap();
2688 cx.executor().run_until_parked();
2689
2690 assert_eq!(
2691 visible_entries_as_strings(&panel, 0..10, cx),
2692 &[
2693 "v src",
2694 " v test",
2695 " first.txt",
2696 " fourth.txt <== selected",
2697 " second.txt",
2698 " third.txt"
2699 ],
2700 "File list should be different after rename confirmation"
2701 );
2702
2703 panel.update_in(cx, |panel, window, cx| {
2704 panel.update_visible_entries(None, false, false, window, cx);
2705 });
2706 cx.executor().run_until_parked();
2707
2708 select_path(&panel, "src/test/first.txt", cx);
2709 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2710 cx.executor().run_until_parked();
2711
2712 workspace
2713 .read_with(cx, |this, cx| {
2714 assert!(
2715 this.recent_navigation_history_iter(cx)
2716 .any(|(project_path, abs_path)| {
2717 project_path.path == Arc::from(rel_path("test/fourth.txt"))
2718 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2719 })
2720 );
2721 })
2722 .unwrap();
2723}
2724
2725// NOTE: This test is skipped on Windows, because on Windows,
2726// when it triggers the lsp store it converts `/src/test/first.txt` into an uri
2727// but it fails with message `"/src\\test\\first.txt" is not parseable as an URI`
2728#[gpui::test]
2729#[cfg_attr(target_os = "windows", ignore)]
2730async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) {
2731 init_test_with_editor(cx);
2732
2733 let fs = FakeFs::new(cx.executor());
2734 fs.insert_tree(
2735 "/src",
2736 json!({
2737 "test": {
2738 "first.txt": "// First Txt file",
2739 "second.txt": "// Second Txt file",
2740 "third.txt": "// Third Txt file",
2741 }
2742 }),
2743 )
2744 .await;
2745
2746 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2747 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2748 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2749 let panel = workspace
2750 .update(cx, |workspace, window, cx| {
2751 let panel = ProjectPanel::new(workspace, window, cx);
2752 workspace.add_panel(panel.clone(), window, cx);
2753 panel
2754 })
2755 .unwrap();
2756 cx.run_until_parked();
2757
2758 select_path(&panel, "src", cx);
2759 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2760 cx.executor().run_until_parked();
2761 assert_eq!(
2762 visible_entries_as_strings(&panel, 0..10, cx),
2763 &[
2764 //
2765 "v src <== selected",
2766 " > test"
2767 ]
2768 );
2769
2770 select_path(&panel, "src/test", cx);
2771 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2772 cx.executor().run_until_parked();
2773 assert_eq!(
2774 visible_entries_as_strings(&panel, 0..10, cx),
2775 &[
2776 //
2777 "v src",
2778 " > test <== selected"
2779 ]
2780 );
2781 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2782 cx.run_until_parked();
2783 panel.update_in(cx, |panel, window, cx| {
2784 assert!(panel.filename_editor.read(cx).is_focused(window));
2785 });
2786
2787 select_path(&panel, "src/test/first.txt", cx);
2788 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2789 cx.executor().run_until_parked();
2790
2791 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2792 cx.executor().run_until_parked();
2793
2794 assert_eq!(
2795 visible_entries_as_strings(&panel, 0..10, cx),
2796 &[
2797 "v src",
2798 " v test",
2799 " [EDITOR: 'first.txt'] <== selected <== marked",
2800 " second.txt",
2801 " third.txt"
2802 ],
2803 );
2804
2805 let confirm = panel.update_in(cx, |panel, window, cx| {
2806 panel
2807 .filename_editor
2808 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2809 panel.confirm_edit(true, window, cx).unwrap()
2810 });
2811 confirm.await.unwrap();
2812 cx.executor().run_until_parked();
2813
2814 assert_eq!(
2815 visible_entries_as_strings(&panel, 0..10, cx),
2816 &[
2817 "v src",
2818 " v test",
2819 " fourth.txt <== selected",
2820 " second.txt",
2821 " third.txt"
2822 ],
2823 "File list should be different after rename confirmation"
2824 );
2825
2826 panel.update_in(cx, |panel, window, cx| {
2827 panel.update_visible_entries(None, false, false, window, cx);
2828 });
2829 cx.executor().run_until_parked();
2830
2831 select_path(&panel, "src/test/second.txt", cx);
2832 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2833 cx.executor().run_until_parked();
2834
2835 workspace
2836 .read_with(cx, |this, cx| {
2837 assert!(
2838 this.recent_navigation_history_iter(cx)
2839 .any(|(project_path, abs_path)| {
2840 project_path.path == Arc::from(rel_path("test/fourth.txt"))
2841 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2842 })
2843 );
2844 })
2845 .unwrap();
2846}
2847
2848#[gpui::test]
2849async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
2850 init_test_with_editor(cx);
2851
2852 let fs = FakeFs::new(cx.executor());
2853 fs.insert_tree(
2854 path!("/root"),
2855 json!({
2856 "tree1": {
2857 ".git": {},
2858 "dir1": {
2859 "modified1.txt": "1",
2860 "unmodified1.txt": "1",
2861 "modified2.txt": "1",
2862 },
2863 "dir2": {
2864 "modified3.txt": "1",
2865 "unmodified2.txt": "1",
2866 },
2867 "modified4.txt": "1",
2868 "unmodified3.txt": "1",
2869 },
2870 "tree2": {
2871 ".git": {},
2872 "dir3": {
2873 "modified5.txt": "1",
2874 "unmodified4.txt": "1",
2875 },
2876 "modified6.txt": "1",
2877 "unmodified5.txt": "1",
2878 }
2879 }),
2880 )
2881 .await;
2882
2883 // Mark files as git modified
2884 fs.set_head_and_index_for_repo(
2885 path!("/root/tree1/.git").as_ref(),
2886 &[
2887 ("dir1/modified1.txt", "modified".into()),
2888 ("dir1/modified2.txt", "modified".into()),
2889 ("modified4.txt", "modified".into()),
2890 ("dir2/modified3.txt", "modified".into()),
2891 ],
2892 );
2893 fs.set_head_and_index_for_repo(
2894 path!("/root/tree2/.git").as_ref(),
2895 &[
2896 ("dir3/modified5.txt", "modified".into()),
2897 ("modified6.txt", "modified".into()),
2898 ],
2899 );
2900
2901 let project = Project::test(
2902 fs.clone(),
2903 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
2904 cx,
2905 )
2906 .await;
2907
2908 let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
2909 let mut worktrees = project.worktrees(cx);
2910 let worktree1 = worktrees.next().unwrap();
2911 let worktree2 = worktrees.next().unwrap();
2912 (
2913 worktree1.read(cx).as_local().unwrap().scan_complete(),
2914 worktree2.read(cx).as_local().unwrap().scan_complete(),
2915 )
2916 });
2917 scan1_complete.await;
2918 scan2_complete.await;
2919 cx.run_until_parked();
2920
2921 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2922 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2923 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2924 cx.run_until_parked();
2925
2926 // Check initial state
2927 assert_eq!(
2928 visible_entries_as_strings(&panel, 0..15, cx),
2929 &[
2930 "v tree1",
2931 " > .git",
2932 " > dir1",
2933 " > dir2",
2934 " modified4.txt",
2935 " unmodified3.txt",
2936 "v tree2",
2937 " > .git",
2938 " > dir3",
2939 " modified6.txt",
2940 " unmodified5.txt"
2941 ],
2942 );
2943
2944 // Test selecting next modified entry
2945 panel.update_in(cx, |panel, window, cx| {
2946 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2947 });
2948 cx.run_until_parked();
2949
2950 assert_eq!(
2951 visible_entries_as_strings(&panel, 0..6, cx),
2952 &[
2953 "v tree1",
2954 " > .git",
2955 " v dir1",
2956 " modified1.txt <== selected",
2957 " modified2.txt",
2958 " unmodified1.txt",
2959 ],
2960 );
2961
2962 panel.update_in(cx, |panel, window, cx| {
2963 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2964 });
2965 cx.run_until_parked();
2966
2967 assert_eq!(
2968 visible_entries_as_strings(&panel, 0..6, cx),
2969 &[
2970 "v tree1",
2971 " > .git",
2972 " v dir1",
2973 " modified1.txt",
2974 " modified2.txt <== selected",
2975 " unmodified1.txt",
2976 ],
2977 );
2978
2979 panel.update_in(cx, |panel, window, cx| {
2980 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2981 });
2982 cx.run_until_parked();
2983
2984 assert_eq!(
2985 visible_entries_as_strings(&panel, 6..9, cx),
2986 &[
2987 " v dir2",
2988 " modified3.txt <== selected",
2989 " unmodified2.txt",
2990 ],
2991 );
2992
2993 panel.update_in(cx, |panel, window, cx| {
2994 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
2995 });
2996 cx.run_until_parked();
2997
2998 assert_eq!(
2999 visible_entries_as_strings(&panel, 9..11, cx),
3000 &[" modified4.txt <== selected", " unmodified3.txt",],
3001 );
3002
3003 panel.update_in(cx, |panel, window, cx| {
3004 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3005 });
3006 cx.run_until_parked();
3007
3008 assert_eq!(
3009 visible_entries_as_strings(&panel, 13..16, cx),
3010 &[
3011 " v dir3",
3012 " modified5.txt <== selected",
3013 " unmodified4.txt",
3014 ],
3015 );
3016
3017 panel.update_in(cx, |panel, window, cx| {
3018 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3019 });
3020 cx.run_until_parked();
3021
3022 assert_eq!(
3023 visible_entries_as_strings(&panel, 16..18, cx),
3024 &[" modified6.txt <== selected", " unmodified5.txt",],
3025 );
3026
3027 // Wraps around to first modified file
3028 panel.update_in(cx, |panel, window, cx| {
3029 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3030 });
3031 cx.run_until_parked();
3032
3033 assert_eq!(
3034 visible_entries_as_strings(&panel, 0..18, cx),
3035 &[
3036 "v tree1",
3037 " > .git",
3038 " v dir1",
3039 " modified1.txt <== selected",
3040 " modified2.txt",
3041 " unmodified1.txt",
3042 " v dir2",
3043 " modified3.txt",
3044 " unmodified2.txt",
3045 " modified4.txt",
3046 " unmodified3.txt",
3047 "v tree2",
3048 " > .git",
3049 " v dir3",
3050 " modified5.txt",
3051 " unmodified4.txt",
3052 " modified6.txt",
3053 " unmodified5.txt",
3054 ],
3055 );
3056
3057 // Wraps around again to last modified file
3058 panel.update_in(cx, |panel, window, cx| {
3059 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3060 });
3061 cx.run_until_parked();
3062
3063 assert_eq!(
3064 visible_entries_as_strings(&panel, 16..18, cx),
3065 &[" modified6.txt <== selected", " unmodified5.txt",],
3066 );
3067
3068 panel.update_in(cx, |panel, window, cx| {
3069 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3070 });
3071 cx.run_until_parked();
3072
3073 assert_eq!(
3074 visible_entries_as_strings(&panel, 13..16, cx),
3075 &[
3076 " v dir3",
3077 " modified5.txt <== selected",
3078 " unmodified4.txt",
3079 ],
3080 );
3081
3082 panel.update_in(cx, |panel, window, cx| {
3083 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3084 });
3085 cx.run_until_parked();
3086
3087 assert_eq!(
3088 visible_entries_as_strings(&panel, 9..11, cx),
3089 &[" modified4.txt <== selected", " unmodified3.txt",],
3090 );
3091
3092 panel.update_in(cx, |panel, window, cx| {
3093 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3094 });
3095 cx.run_until_parked();
3096
3097 assert_eq!(
3098 visible_entries_as_strings(&panel, 6..9, cx),
3099 &[
3100 " v dir2",
3101 " modified3.txt <== selected",
3102 " unmodified2.txt",
3103 ],
3104 );
3105
3106 panel.update_in(cx, |panel, window, cx| {
3107 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3108 });
3109 cx.run_until_parked();
3110
3111 assert_eq!(
3112 visible_entries_as_strings(&panel, 0..6, cx),
3113 &[
3114 "v tree1",
3115 " > .git",
3116 " v dir1",
3117 " modified1.txt",
3118 " modified2.txt <== selected",
3119 " unmodified1.txt",
3120 ],
3121 );
3122
3123 panel.update_in(cx, |panel, window, cx| {
3124 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3125 });
3126 cx.run_until_parked();
3127
3128 assert_eq!(
3129 visible_entries_as_strings(&panel, 0..6, cx),
3130 &[
3131 "v tree1",
3132 " > .git",
3133 " v dir1",
3134 " modified1.txt <== selected",
3135 " modified2.txt",
3136 " unmodified1.txt",
3137 ],
3138 );
3139}
3140
3141#[gpui::test]
3142async fn test_select_directory(cx: &mut gpui::TestAppContext) {
3143 init_test_with_editor(cx);
3144
3145 let fs = FakeFs::new(cx.executor());
3146 fs.insert_tree(
3147 "/project_root",
3148 json!({
3149 "dir_1": {
3150 "nested_dir": {
3151 "file_a.py": "# File contents",
3152 }
3153 },
3154 "file_1.py": "# File contents",
3155 "dir_2": {
3156
3157 },
3158 "dir_3": {
3159
3160 },
3161 "file_2.py": "# File contents",
3162 "dir_4": {
3163
3164 },
3165 }),
3166 )
3167 .await;
3168
3169 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3170 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3171 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3172 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3173 cx.run_until_parked();
3174
3175 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3176 cx.executor().run_until_parked();
3177 select_path(&panel, "project_root/dir_1", cx);
3178 cx.executor().run_until_parked();
3179 assert_eq!(
3180 visible_entries_as_strings(&panel, 0..10, cx),
3181 &[
3182 "v project_root",
3183 " > dir_1 <== selected",
3184 " > dir_2",
3185 " > dir_3",
3186 " > dir_4",
3187 " file_1.py",
3188 " file_2.py",
3189 ]
3190 );
3191 panel.update_in(cx, |panel, window, cx| {
3192 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3193 });
3194
3195 assert_eq!(
3196 visible_entries_as_strings(&panel, 0..10, cx),
3197 &[
3198 "v project_root <== selected",
3199 " > dir_1",
3200 " > dir_2",
3201 " > dir_3",
3202 " > dir_4",
3203 " file_1.py",
3204 " file_2.py",
3205 ]
3206 );
3207
3208 panel.update_in(cx, |panel, window, cx| {
3209 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3210 });
3211
3212 assert_eq!(
3213 visible_entries_as_strings(&panel, 0..10, cx),
3214 &[
3215 "v project_root",
3216 " > dir_1",
3217 " > dir_2",
3218 " > dir_3",
3219 " > dir_4 <== selected",
3220 " file_1.py",
3221 " file_2.py",
3222 ]
3223 );
3224
3225 panel.update_in(cx, |panel, window, cx| {
3226 panel.select_next_directory(&SelectNextDirectory, window, cx)
3227 });
3228
3229 assert_eq!(
3230 visible_entries_as_strings(&panel, 0..10, cx),
3231 &[
3232 "v project_root <== selected",
3233 " > dir_1",
3234 " > dir_2",
3235 " > dir_3",
3236 " > dir_4",
3237 " file_1.py",
3238 " file_2.py",
3239 ]
3240 );
3241}
3242
3243#[gpui::test]
3244async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
3245 init_test_with_editor(cx);
3246
3247 let fs = FakeFs::new(cx.executor());
3248 fs.insert_tree(
3249 "/project_root",
3250 json!({
3251 "dir_1": {
3252 "nested_dir": {
3253 "file_a.py": "# File contents",
3254 }
3255 },
3256 "file_1.py": "# File contents",
3257 "file_2.py": "# File contents",
3258 "zdir_2": {
3259 "nested_dir2": {
3260 "file_b.py": "# File contents",
3261 }
3262 },
3263 }),
3264 )
3265 .await;
3266
3267 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3268 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3269 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3270 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3271 cx.run_until_parked();
3272
3273 assert_eq!(
3274 visible_entries_as_strings(&panel, 0..10, cx),
3275 &[
3276 "v project_root",
3277 " > dir_1",
3278 " > zdir_2",
3279 " file_1.py",
3280 " file_2.py",
3281 ]
3282 );
3283 panel.update_in(cx, |panel, window, cx| {
3284 panel.select_first(&SelectFirst, window, cx)
3285 });
3286
3287 assert_eq!(
3288 visible_entries_as_strings(&panel, 0..10, cx),
3289 &[
3290 "v project_root <== selected",
3291 " > dir_1",
3292 " > zdir_2",
3293 " file_1.py",
3294 " file_2.py",
3295 ]
3296 );
3297
3298 panel.update_in(cx, |panel, window, cx| {
3299 panel.select_last(&SelectLast, window, cx)
3300 });
3301
3302 assert_eq!(
3303 visible_entries_as_strings(&panel, 0..10, cx),
3304 &[
3305 "v project_root",
3306 " > dir_1",
3307 " > zdir_2",
3308 " file_1.py",
3309 " file_2.py <== selected",
3310 ]
3311 );
3312
3313 cx.update(|_, cx| {
3314 let settings = *ProjectPanelSettings::get_global(cx);
3315 ProjectPanelSettings::override_global(
3316 ProjectPanelSettings {
3317 hide_root: true,
3318 ..settings
3319 },
3320 cx,
3321 );
3322 });
3323
3324 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3325 cx.run_until_parked();
3326
3327 #[rustfmt::skip]
3328 assert_eq!(
3329 visible_entries_as_strings(&panel, 0..10, cx),
3330 &[
3331 "> dir_1",
3332 "> zdir_2",
3333 " file_1.py",
3334 " file_2.py",
3335 ],
3336 "With hide_root=true, root should be hidden"
3337 );
3338
3339 panel.update_in(cx, |panel, window, cx| {
3340 panel.select_first(&SelectFirst, window, cx)
3341 });
3342
3343 assert_eq!(
3344 visible_entries_as_strings(&panel, 0..10, cx),
3345 &[
3346 "> dir_1 <== selected",
3347 "> zdir_2",
3348 " file_1.py",
3349 " file_2.py",
3350 ],
3351 "With hide_root=true, first entry should be dir_1, not the hidden root"
3352 );
3353}
3354
3355#[gpui::test]
3356async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3357 init_test_with_editor(cx);
3358
3359 let fs = FakeFs::new(cx.executor());
3360 fs.insert_tree(
3361 "/project_root",
3362 json!({
3363 "dir_1": {
3364 "nested_dir": {
3365 "file_a.py": "# File contents",
3366 }
3367 },
3368 "file_1.py": "# File contents",
3369 }),
3370 )
3371 .await;
3372
3373 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3374 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3375 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3376 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3377 cx.run_until_parked();
3378
3379 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3380 cx.executor().run_until_parked();
3381 select_path(&panel, "project_root/dir_1", cx);
3382 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3383 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3384 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3385 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3386 cx.executor().run_until_parked();
3387 assert_eq!(
3388 visible_entries_as_strings(&panel, 0..10, cx),
3389 &[
3390 "v project_root",
3391 " v dir_1",
3392 " > nested_dir <== selected",
3393 " file_1.py",
3394 ]
3395 );
3396}
3397
3398#[gpui::test]
3399async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3400 init_test_with_editor(cx);
3401
3402 let fs = FakeFs::new(cx.executor());
3403 fs.insert_tree(
3404 "/project_root",
3405 json!({
3406 "dir_1": {
3407 "nested_dir": {
3408 "file_a.py": "# File contents",
3409 "file_b.py": "# File contents",
3410 "file_c.py": "# File contents",
3411 },
3412 "file_1.py": "# File contents",
3413 "file_2.py": "# File contents",
3414 "file_3.py": "# File contents",
3415 },
3416 "dir_2": {
3417 "file_1.py": "# File contents",
3418 "file_2.py": "# File contents",
3419 "file_3.py": "# File contents",
3420 }
3421 }),
3422 )
3423 .await;
3424
3425 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3426 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3427 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3428 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3429 cx.run_until_parked();
3430
3431 panel.update_in(cx, |panel, window, cx| {
3432 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3433 });
3434 cx.executor().run_until_parked();
3435 assert_eq!(
3436 visible_entries_as_strings(&panel, 0..10, cx),
3437 &["v project_root", " > dir_1", " > dir_2",]
3438 );
3439
3440 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3441 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3442 cx.executor().run_until_parked();
3443 assert_eq!(
3444 visible_entries_as_strings(&panel, 0..10, cx),
3445 &[
3446 "v project_root",
3447 " v dir_1 <== selected",
3448 " > nested_dir",
3449 " file_1.py",
3450 " file_2.py",
3451 " file_3.py",
3452 " > dir_2",
3453 ]
3454 );
3455}
3456
3457#[gpui::test]
3458async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
3459 init_test_with_editor(cx);
3460
3461 let fs = FakeFs::new(cx.executor());
3462 let worktree_content = json!({
3463 "dir_1": {
3464 "file_1.py": "# File contents",
3465 },
3466 "dir_2": {
3467 "file_1.py": "# File contents",
3468 }
3469 });
3470
3471 fs.insert_tree("/project_root_1", worktree_content.clone())
3472 .await;
3473 fs.insert_tree("/project_root_2", worktree_content).await;
3474
3475 let project = Project::test(
3476 fs.clone(),
3477 ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
3478 cx,
3479 )
3480 .await;
3481 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3482 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3483 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3484 cx.run_until_parked();
3485
3486 panel.update_in(cx, |panel, window, cx| {
3487 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3488 });
3489 cx.executor().run_until_parked();
3490 assert_eq!(
3491 visible_entries_as_strings(&panel, 0..10, cx),
3492 &["> project_root_1", "> project_root_2",]
3493 );
3494}
3495
3496#[gpui::test]
3497async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
3498 init_test_with_editor(cx);
3499
3500 let fs = FakeFs::new(cx.executor());
3501 fs.insert_tree(
3502 "/project_root",
3503 json!({
3504 "dir_1": {
3505 "nested_dir": {
3506 "file_a.py": "# File contents",
3507 "file_b.py": "# File contents",
3508 "file_c.py": "# File contents",
3509 },
3510 "file_1.py": "# File contents",
3511 "file_2.py": "# File contents",
3512 "file_3.py": "# File contents",
3513 },
3514 "dir_2": {
3515 "file_1.py": "# File contents",
3516 "file_2.py": "# File contents",
3517 "file_3.py": "# File contents",
3518 }
3519 }),
3520 )
3521 .await;
3522
3523 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3524 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3525 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3526 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3527 cx.run_until_parked();
3528
3529 // Open project_root/dir_1 to ensure that a nested directory is expanded
3530 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3531 cx.executor().run_until_parked();
3532 assert_eq!(
3533 visible_entries_as_strings(&panel, 0..10, cx),
3534 &[
3535 "v project_root",
3536 " v dir_1 <== selected",
3537 " > nested_dir",
3538 " file_1.py",
3539 " file_2.py",
3540 " file_3.py",
3541 " > dir_2",
3542 ]
3543 );
3544
3545 // Close root directory
3546 toggle_expand_dir(&panel, "project_root", cx);
3547 cx.executor().run_until_parked();
3548 assert_eq!(
3549 visible_entries_as_strings(&panel, 0..10, cx),
3550 &["> project_root <== selected"]
3551 );
3552
3553 // Run collapse_all_entries and make sure root is not expanded
3554 panel.update_in(cx, |panel, window, cx| {
3555 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3556 });
3557 cx.executor().run_until_parked();
3558 assert_eq!(
3559 visible_entries_as_strings(&panel, 0..10, cx),
3560 &["> project_root <== selected"]
3561 );
3562}
3563
3564#[gpui::test]
3565async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3566 init_test(cx);
3567
3568 let fs = FakeFs::new(cx.executor());
3569 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
3570 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
3571 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3572 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3573 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3574 cx.run_until_parked();
3575
3576 // Make a new buffer with no backing file
3577 workspace
3578 .update(cx, |workspace, window, cx| {
3579 Editor::new_file(workspace, &Default::default(), window, cx)
3580 })
3581 .unwrap();
3582
3583 cx.executor().run_until_parked();
3584
3585 // "Save as" the buffer, creating a new backing file for it
3586 let save_task = workspace
3587 .update(cx, |workspace, window, cx| {
3588 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3589 })
3590 .unwrap();
3591
3592 cx.executor().run_until_parked();
3593 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
3594 save_task.await.unwrap();
3595
3596 // Rename the file
3597 select_path(&panel, "root/new", cx);
3598 assert_eq!(
3599 visible_entries_as_strings(&panel, 0..10, cx),
3600 &["v root", " new <== selected <== marked"]
3601 );
3602 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3603 panel.update_in(cx, |panel, window, cx| {
3604 panel
3605 .filename_editor
3606 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
3607 });
3608 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3609
3610 cx.executor().run_until_parked();
3611 assert_eq!(
3612 visible_entries_as_strings(&panel, 0..10, cx),
3613 &["v root", " newer <== selected"]
3614 );
3615
3616 workspace
3617 .update(cx, |workspace, window, cx| {
3618 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3619 })
3620 .unwrap()
3621 .await
3622 .unwrap();
3623
3624 cx.executor().run_until_parked();
3625 // assert that saving the file doesn't restore "new"
3626 assert_eq!(
3627 visible_entries_as_strings(&panel, 0..10, cx),
3628 &["v root", " newer <== selected"]
3629 );
3630}
3631
3632// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
3633// you can't rename a directory which some program has already open. This is a
3634// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
3635// to it, and thus renaming it will fail on Windows.
3636// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
3637// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
3638#[gpui::test]
3639#[cfg_attr(target_os = "windows", ignore)]
3640async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
3641 init_test_with_editor(cx);
3642
3643 let fs = FakeFs::new(cx.executor());
3644 fs.insert_tree(
3645 "/root1",
3646 json!({
3647 "dir1": {
3648 "file1.txt": "content 1",
3649 },
3650 }),
3651 )
3652 .await;
3653
3654 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3655 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3656 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3657 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3658 cx.run_until_parked();
3659
3660 toggle_expand_dir(&panel, "root1/dir1", cx);
3661
3662 assert_eq!(
3663 visible_entries_as_strings(&panel, 0..20, cx),
3664 &["v root1", " v dir1 <== selected", " file1.txt",],
3665 "Initial state with worktrees"
3666 );
3667
3668 select_path(&panel, "root1", cx);
3669 assert_eq!(
3670 visible_entries_as_strings(&panel, 0..20, cx),
3671 &["v root1 <== selected", " v dir1", " file1.txt",],
3672 );
3673
3674 // Rename root1 to new_root1
3675 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3676
3677 assert_eq!(
3678 visible_entries_as_strings(&panel, 0..20, cx),
3679 &[
3680 "v [EDITOR: 'root1'] <== selected",
3681 " v dir1",
3682 " file1.txt",
3683 ],
3684 );
3685
3686 let confirm = panel.update_in(cx, |panel, window, cx| {
3687 panel
3688 .filename_editor
3689 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3690 panel.confirm_edit(true, window, cx).unwrap()
3691 });
3692 confirm.await.unwrap();
3693 cx.run_until_parked();
3694 assert_eq!(
3695 visible_entries_as_strings(&panel, 0..20, cx),
3696 &[
3697 "v new_root1 <== selected",
3698 " v dir1",
3699 " file1.txt",
3700 ],
3701 "Should update worktree name"
3702 );
3703
3704 // Ensure internal paths have been updated
3705 select_path(&panel, "new_root1/dir1/file1.txt", cx);
3706 assert_eq!(
3707 visible_entries_as_strings(&panel, 0..20, cx),
3708 &[
3709 "v new_root1",
3710 " v dir1",
3711 " file1.txt <== selected",
3712 ],
3713 "Files in renamed worktree are selectable"
3714 );
3715}
3716
3717#[gpui::test]
3718async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
3719 init_test_with_editor(cx);
3720
3721 let fs = FakeFs::new(cx.executor());
3722 fs.insert_tree(
3723 "/root1",
3724 json!({
3725 "dir1": { "file1.txt": "content" },
3726 "file2.txt": "content",
3727 }),
3728 )
3729 .await;
3730 fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
3731 .await;
3732
3733 // Test 1: Single worktree, hide_root=true - rename should be blocked
3734 {
3735 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3736 let workspace =
3737 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3738 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3739
3740 cx.update(|_, cx| {
3741 let settings = *ProjectPanelSettings::get_global(cx);
3742 ProjectPanelSettings::override_global(
3743 ProjectPanelSettings {
3744 hide_root: true,
3745 ..settings
3746 },
3747 cx,
3748 );
3749 });
3750
3751 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3752 cx.run_until_parked();
3753
3754 panel.update(cx, |panel, cx| {
3755 let project = panel.project.read(cx);
3756 let worktree = project.visible_worktrees(cx).next().unwrap();
3757 let root_entry = worktree.read(cx).root_entry().unwrap();
3758 panel.state.selection = Some(SelectedEntry {
3759 worktree_id: worktree.read(cx).id(),
3760 entry_id: root_entry.id,
3761 });
3762 });
3763
3764 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3765
3766 assert!(
3767 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3768 "Rename should be blocked when hide_root=true with single worktree"
3769 );
3770 }
3771
3772 // Test 2: Multiple worktrees, hide_root=true - rename should work
3773 {
3774 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3775 let workspace =
3776 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3777 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3778
3779 cx.update(|_, cx| {
3780 let settings = *ProjectPanelSettings::get_global(cx);
3781 ProjectPanelSettings::override_global(
3782 ProjectPanelSettings {
3783 hide_root: true,
3784 ..settings
3785 },
3786 cx,
3787 );
3788 });
3789
3790 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3791 cx.run_until_parked();
3792
3793 select_path(&panel, "root1", cx);
3794 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3795
3796 #[cfg(target_os = "windows")]
3797 assert!(
3798 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
3799 "Rename should be blocked on Windows even with multiple worktrees"
3800 );
3801
3802 #[cfg(not(target_os = "windows"))]
3803 {
3804 assert!(
3805 panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
3806 "Rename should work with multiple worktrees on non-Windows when hide_root=true"
3807 );
3808 panel.update_in(cx, |panel, window, cx| {
3809 panel.cancel(&menu::Cancel, window, cx)
3810 });
3811 }
3812 }
3813}
3814
3815#[gpui::test]
3816async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3817 init_test_with_editor(cx);
3818 let fs = FakeFs::new(cx.executor());
3819 fs.insert_tree(
3820 "/project_root",
3821 json!({
3822 "dir_1": {
3823 "nested_dir": {
3824 "file_a.py": "# File contents",
3825 }
3826 },
3827 "file_1.py": "# File contents",
3828 }),
3829 )
3830 .await;
3831
3832 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3833 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
3834 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3835 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3836 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3837 cx.run_until_parked();
3838
3839 cx.update(|window, cx| {
3840 panel.update(cx, |this, cx| {
3841 this.select_next(&Default::default(), window, cx);
3842 this.expand_selected_entry(&Default::default(), window, cx);
3843 })
3844 });
3845 cx.run_until_parked();
3846
3847 cx.update(|window, cx| {
3848 panel.update(cx, |this, cx| {
3849 this.expand_selected_entry(&Default::default(), window, cx);
3850 })
3851 });
3852 cx.run_until_parked();
3853
3854 cx.update(|window, cx| {
3855 panel.update(cx, |this, cx| {
3856 this.select_next(&Default::default(), window, cx);
3857 this.expand_selected_entry(&Default::default(), window, cx);
3858 })
3859 });
3860 cx.run_until_parked();
3861
3862 cx.update(|window, cx| {
3863 panel.update(cx, |this, cx| {
3864 this.select_next(&Default::default(), window, cx);
3865 })
3866 });
3867 cx.run_until_parked();
3868
3869 assert_eq!(
3870 visible_entries_as_strings(&panel, 0..10, cx),
3871 &[
3872 "v project_root",
3873 " v dir_1",
3874 " v nested_dir",
3875 " file_a.py <== selected",
3876 " file_1.py",
3877 ]
3878 );
3879 let modifiers_with_shift = gpui::Modifiers {
3880 shift: true,
3881 ..Default::default()
3882 };
3883 cx.run_until_parked();
3884 cx.simulate_modifiers_change(modifiers_with_shift);
3885 cx.update(|window, cx| {
3886 panel.update(cx, |this, cx| {
3887 this.select_next(&Default::default(), window, cx);
3888 })
3889 });
3890 assert_eq!(
3891 visible_entries_as_strings(&panel, 0..10, cx),
3892 &[
3893 "v project_root",
3894 " v dir_1",
3895 " v nested_dir",
3896 " file_a.py",
3897 " file_1.py <== selected <== marked",
3898 ]
3899 );
3900 cx.update(|window, cx| {
3901 panel.update(cx, |this, cx| {
3902 this.select_previous(&Default::default(), window, cx);
3903 })
3904 });
3905 assert_eq!(
3906 visible_entries_as_strings(&panel, 0..10, cx),
3907 &[
3908 "v project_root",
3909 " v dir_1",
3910 " v nested_dir",
3911 " file_a.py <== selected <== marked",
3912 " file_1.py <== marked",
3913 ]
3914 );
3915 cx.update(|window, cx| {
3916 panel.update(cx, |this, cx| {
3917 let drag = DraggedSelection {
3918 active_selection: this.state.selection.unwrap(),
3919 marked_selections: this.marked_entries.clone().into(),
3920 };
3921 let target_entry = this
3922 .project
3923 .read(cx)
3924 .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
3925 .unwrap();
3926 this.drag_onto(&drag, target_entry.id, false, window, cx);
3927 });
3928 });
3929 cx.run_until_parked();
3930 assert_eq!(
3931 visible_entries_as_strings(&panel, 0..10, cx),
3932 &[
3933 "v project_root",
3934 " v dir_1",
3935 " v nested_dir",
3936 " file_1.py <== marked",
3937 " file_a.py <== selected <== marked",
3938 ]
3939 );
3940 // ESC clears out all marks
3941 cx.update(|window, cx| {
3942 panel.update(cx, |this, cx| {
3943 this.cancel(&menu::Cancel, window, cx);
3944 })
3945 });
3946 assert_eq!(
3947 visible_entries_as_strings(&panel, 0..10, cx),
3948 &[
3949 "v project_root",
3950 " v dir_1",
3951 " v nested_dir",
3952 " file_1.py",
3953 " file_a.py <== selected",
3954 ]
3955 );
3956 // ESC clears out all marks
3957 cx.update(|window, cx| {
3958 panel.update(cx, |this, cx| {
3959 this.select_previous(&SelectPrevious, window, cx);
3960 this.select_next(&SelectNext, window, cx);
3961 })
3962 });
3963 assert_eq!(
3964 visible_entries_as_strings(&panel, 0..10, cx),
3965 &[
3966 "v project_root",
3967 " v dir_1",
3968 " v nested_dir",
3969 " file_1.py <== marked",
3970 " file_a.py <== selected <== marked",
3971 ]
3972 );
3973 cx.simulate_modifiers_change(Default::default());
3974 cx.update(|window, cx| {
3975 panel.update(cx, |this, cx| {
3976 this.cut(&Cut, window, cx);
3977 this.select_previous(&SelectPrevious, window, cx);
3978 this.select_previous(&SelectPrevious, window, cx);
3979
3980 this.paste(&Paste, window, cx);
3981 this.update_visible_entries(None, false, false, window, cx);
3982 })
3983 });
3984 cx.run_until_parked();
3985 assert_eq!(
3986 visible_entries_as_strings(&panel, 0..10, cx),
3987 &[
3988 "v project_root",
3989 " v dir_1",
3990 " v nested_dir",
3991 " file_1.py <== marked",
3992 " file_a.py <== selected <== marked",
3993 ]
3994 );
3995 cx.simulate_modifiers_change(modifiers_with_shift);
3996 cx.update(|window, cx| {
3997 panel.update(cx, |this, cx| {
3998 this.expand_selected_entry(&Default::default(), window, cx);
3999 this.select_next(&SelectNext, window, cx);
4000 this.select_next(&SelectNext, window, cx);
4001 })
4002 });
4003 submit_deletion(&panel, cx);
4004 assert_eq!(
4005 visible_entries_as_strings(&panel, 0..10, cx),
4006 &[
4007 "v project_root",
4008 " v dir_1",
4009 " v nested_dir <== selected",
4010 ]
4011 );
4012}
4013
4014#[gpui::test]
4015async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
4016 init_test(cx);
4017
4018 let fs = FakeFs::new(cx.executor());
4019 fs.insert_tree(
4020 "/root",
4021 json!({
4022 "a": {
4023 "b": {
4024 "c": {
4025 "d": {}
4026 }
4027 }
4028 },
4029 "target_destination": {}
4030 }),
4031 )
4032 .await;
4033
4034 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4035 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4036 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4037
4038 cx.update(|_, cx| {
4039 let settings = *ProjectPanelSettings::get_global(cx);
4040 ProjectPanelSettings::override_global(
4041 ProjectPanelSettings {
4042 auto_fold_dirs: true,
4043 ..settings
4044 },
4045 cx,
4046 );
4047 });
4048
4049 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4050 cx.run_until_parked();
4051
4052 // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
4053 select_path(&panel, "root/a/b/c/d", cx);
4054 panel.update_in(cx, |panel, window, cx| {
4055 let drag = DraggedSelection {
4056 active_selection: *panel.state.selection.as_ref().unwrap(),
4057 marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
4058 };
4059 let target_entry = panel
4060 .project
4061 .read(cx)
4062 .visible_worktrees(cx)
4063 .next()
4064 .unwrap()
4065 .read(cx)
4066 .entry_for_path(rel_path("target_destination"))
4067 .unwrap();
4068 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4069 });
4070 cx.executor().run_until_parked();
4071
4072 assert_eq!(
4073 visible_entries_as_strings(&panel, 0..10, cx),
4074 &[
4075 "v root",
4076 " > a/b/c",
4077 " > target_destination/d <== selected"
4078 ],
4079 "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
4080 );
4081
4082 // Reset
4083 select_path(&panel, "root/target_destination/d", cx);
4084 panel.update_in(cx, |panel, window, cx| {
4085 let drag = DraggedSelection {
4086 active_selection: *panel.state.selection.as_ref().unwrap(),
4087 marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
4088 };
4089 let target_entry = panel
4090 .project
4091 .read(cx)
4092 .visible_worktrees(cx)
4093 .next()
4094 .unwrap()
4095 .read(cx)
4096 .entry_for_path(rel_path("a/b/c"))
4097 .unwrap();
4098 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4099 });
4100 cx.executor().run_until_parked();
4101
4102 // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
4103 select_path(&panel, "root/a/b", cx);
4104 panel.update_in(cx, |panel, window, cx| {
4105 let drag = DraggedSelection {
4106 active_selection: *panel.state.selection.as_ref().unwrap(),
4107 marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
4108 };
4109 let target_entry = panel
4110 .project
4111 .read(cx)
4112 .visible_worktrees(cx)
4113 .next()
4114 .unwrap()
4115 .read(cx)
4116 .entry_for_path(rel_path("target_destination"))
4117 .unwrap();
4118 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4119 });
4120 cx.executor().run_until_parked();
4121
4122 assert_eq!(
4123 visible_entries_as_strings(&panel, 0..10, cx),
4124 &["v root", " v a", " > target_destination/b/c/d"],
4125 "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
4126 );
4127
4128 // Reset
4129 select_path(&panel, "root/target_destination/b", cx);
4130 panel.update_in(cx, |panel, window, cx| {
4131 let drag = DraggedSelection {
4132 active_selection: *panel.state.selection.as_ref().unwrap(),
4133 marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
4134 };
4135 let target_entry = panel
4136 .project
4137 .read(cx)
4138 .visible_worktrees(cx)
4139 .next()
4140 .unwrap()
4141 .read(cx)
4142 .entry_for_path(rel_path("a"))
4143 .unwrap();
4144 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4145 });
4146 cx.executor().run_until_parked();
4147
4148 // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
4149 select_path(&panel, "root/a", cx);
4150 panel.update_in(cx, |panel, window, cx| {
4151 let drag = DraggedSelection {
4152 active_selection: *panel.state.selection.as_ref().unwrap(),
4153 marked_selections: Arc::new([*panel.state.selection.as_ref().unwrap()]),
4154 };
4155 let target_entry = panel
4156 .project
4157 .read(cx)
4158 .visible_worktrees(cx)
4159 .next()
4160 .unwrap()
4161 .read(cx)
4162 .entry_for_path(rel_path("target_destination"))
4163 .unwrap();
4164 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4165 });
4166 cx.executor().run_until_parked();
4167
4168 assert_eq!(
4169 visible_entries_as_strings(&panel, 0..10, cx),
4170 &["v root", " > target_destination/a/b/c/d"],
4171 "Moving first directory 'a' should move whole 'a/b/c/d' chain"
4172 );
4173}
4174
4175#[gpui::test]
4176async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) {
4177 init_test(cx);
4178
4179 let fs = FakeFs::new(cx.executor());
4180 fs.insert_tree(
4181 "/root",
4182 json!({
4183 "a": {
4184 "b": {
4185 "c": {}
4186 }
4187 },
4188 "e": {
4189 "f": {
4190 "g": {}
4191 }
4192 },
4193 "target": {}
4194 }),
4195 )
4196 .await;
4197
4198 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4199 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4200 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4201
4202 cx.update(|_, cx| {
4203 let settings = *ProjectPanelSettings::get_global(cx);
4204 ProjectPanelSettings::override_global(
4205 ProjectPanelSettings {
4206 auto_fold_dirs: true,
4207 ..settings
4208 },
4209 cx,
4210 );
4211 });
4212
4213 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4214 cx.run_until_parked();
4215
4216 assert_eq!(
4217 visible_entries_as_strings(&panel, 0..10, cx),
4218 &["v root", " > a/b/c", " > e/f/g", " > target"]
4219 );
4220
4221 select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx);
4222 select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx);
4223
4224 panel.update_in(cx, |panel, window, cx| {
4225 let drag = DraggedSelection {
4226 active_selection: *panel.state.selection.as_ref().unwrap(),
4227 marked_selections: panel.marked_entries.clone().into(),
4228 };
4229 let target_entry = panel
4230 .project
4231 .read(cx)
4232 .visible_worktrees(cx)
4233 .next()
4234 .unwrap()
4235 .read(cx)
4236 .entry_for_path(rel_path("target"))
4237 .unwrap();
4238 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4239 });
4240 cx.executor().run_until_parked();
4241
4242 // After dragging 'b/c' and 'f/g' should be moved to target
4243 assert_eq!(
4244 visible_entries_as_strings(&panel, 0..10, cx),
4245 &[
4246 "v root",
4247 " > a",
4248 " > e",
4249 " v target",
4250 " > b/c",
4251 " > f/g <== selected <== marked"
4252 ],
4253 "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'"
4254 );
4255}
4256
4257#[gpui::test]
4258async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4259 init_test(cx);
4260
4261 let fs = FakeFs::new(cx.executor());
4262 fs.insert_tree(
4263 "/root_a",
4264 json!({
4265 "src": {
4266 "lib.rs": "",
4267 "main.rs": ""
4268 },
4269 "docs": {
4270 "guide.md": ""
4271 },
4272 "multi": {
4273 "alpha.txt": "",
4274 "beta.txt": ""
4275 }
4276 }),
4277 )
4278 .await;
4279 fs.insert_tree(
4280 "/root_b",
4281 json!({
4282 "dst": {
4283 "existing.md": ""
4284 },
4285 "target.txt": ""
4286 }),
4287 )
4288 .await;
4289
4290 let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4291 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4292 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4293 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4294 cx.run_until_parked();
4295
4296 // Case 1: move a file onto a directory in another worktree.
4297 select_path(&panel, "root_a/src/main.rs", cx);
4298 drag_selection_to(&panel, "root_b/dst", false, cx);
4299 assert!(
4300 find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4301 "Dragged file should appear under destination worktree"
4302 );
4303 assert_eq!(
4304 find_project_entry(&panel, "root_a/src/main.rs", cx),
4305 None,
4306 "Dragged file should be removed from the source worktree"
4307 );
4308
4309 // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4310 select_path(&panel, "root_a/docs/guide.md", cx);
4311 drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4312 assert!(
4313 find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4314 "Dropping onto a file should place the entry beside the target file"
4315 );
4316 assert_eq!(
4317 find_project_entry(&panel, "root_a/docs/guide.md", cx),
4318 None,
4319 "Source file should be removed after the move"
4320 );
4321
4322 // Case 3: move an entire directory.
4323 select_path(&panel, "root_a/src", cx);
4324 drag_selection_to(&panel, "root_b/dst", false, cx);
4325 assert!(
4326 find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4327 "Dragging a directory should move its nested contents"
4328 );
4329 assert_eq!(
4330 find_project_entry(&panel, "root_a/src", cx),
4331 None,
4332 "Directory should no longer exist in the source worktree"
4333 );
4334
4335 // Case 4: multi-selection drag between worktrees.
4336 panel.update(cx, |panel, _| panel.marked_entries.clear());
4337 select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4338 select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4339 drag_selection_to(&panel, "root_b/dst", false, cx);
4340 assert!(
4341 find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4342 && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4343 "All marked entries should move to the destination worktree"
4344 );
4345 assert_eq!(
4346 find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4347 None,
4348 "Marked entries should be removed from the origin worktree"
4349 );
4350 assert_eq!(
4351 find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4352 None,
4353 "Marked entries should be removed from the origin worktree"
4354 );
4355}
4356
4357#[gpui::test]
4358async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4359 init_test(cx);
4360
4361 let fs = FakeFs::new(cx.executor());
4362 fs.insert_tree(
4363 "/root",
4364 json!({
4365 "src": {
4366 "folder1": {
4367 "mod.rs": "// folder1 mod"
4368 },
4369 "folder2": {
4370 "mod.rs": "// folder2 mod"
4371 },
4372 "folder3": {
4373 "mod.rs": "// folder3 mod",
4374 "helper.rs": "// helper"
4375 },
4376 "main.rs": ""
4377 }
4378 }),
4379 )
4380 .await;
4381
4382 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4383 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4384 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4385 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4386 cx.run_until_parked();
4387
4388 toggle_expand_dir(&panel, "root/src", cx);
4389 toggle_expand_dir(&panel, "root/src/folder1", cx);
4390 toggle_expand_dir(&panel, "root/src/folder2", cx);
4391 toggle_expand_dir(&panel, "root/src/folder3", cx);
4392 cx.run_until_parked();
4393
4394 // Case 1: Dragging a folder and a file from a sibling folder together.
4395 panel.update(cx, |panel, _| panel.marked_entries.clear());
4396 select_path_with_mark(&panel, "root/src/folder1", cx);
4397 select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4398
4399 drag_selection_to(&panel, "root", false, cx);
4400
4401 assert!(
4402 find_project_entry(&panel, "root/folder1", cx).is_some(),
4403 "folder1 should be at root after drag"
4404 );
4405 assert!(
4406 find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4407 "folder1/mod.rs should still be inside folder1 after drag"
4408 );
4409 assert_eq!(
4410 find_project_entry(&panel, "root/src/folder1", cx),
4411 None,
4412 "folder1 should no longer be in src"
4413 );
4414 assert!(
4415 find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4416 "mod.rs from folder2 should be at root"
4417 );
4418
4419 // Case 2: Dragging a folder and its own child together.
4420 panel.update(cx, |panel, _| panel.marked_entries.clear());
4421 select_path_with_mark(&panel, "root/src/folder3", cx);
4422 select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4423
4424 drag_selection_to(&panel, "root", false, cx);
4425
4426 assert!(
4427 find_project_entry(&panel, "root/folder3", cx).is_some(),
4428 "folder3 should be at root after drag"
4429 );
4430 assert!(
4431 find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4432 "folder3/mod.rs should still be inside folder3"
4433 );
4434 assert!(
4435 find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4436 "folder3/helper.rs should still be inside folder3"
4437 );
4438}
4439
4440#[gpui::test]
4441async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4442 init_test_with_editor(cx);
4443 cx.update(|cx| {
4444 cx.update_global::<SettingsStore, _>(|store, cx| {
4445 store.update_user_settings(cx, |settings| {
4446 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4447 settings
4448 .project_panel
4449 .get_or_insert_default()
4450 .auto_reveal_entries = Some(false);
4451 });
4452 })
4453 });
4454
4455 let fs = FakeFs::new(cx.background_executor.clone());
4456 fs.insert_tree(
4457 "/project_root",
4458 json!({
4459 ".git": {},
4460 ".gitignore": "**/gitignored_dir",
4461 "dir_1": {
4462 "file_1.py": "# File 1_1 contents",
4463 "file_2.py": "# File 1_2 contents",
4464 "file_3.py": "# File 1_3 contents",
4465 "gitignored_dir": {
4466 "file_a.py": "# File contents",
4467 "file_b.py": "# File contents",
4468 "file_c.py": "# File contents",
4469 },
4470 },
4471 "dir_2": {
4472 "file_1.py": "# File 2_1 contents",
4473 "file_2.py": "# File 2_2 contents",
4474 "file_3.py": "# File 2_3 contents",
4475 }
4476 }),
4477 )
4478 .await;
4479
4480 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4481 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4482 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4483 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4484 cx.run_until_parked();
4485
4486 assert_eq!(
4487 visible_entries_as_strings(&panel, 0..20, cx),
4488 &[
4489 "v project_root",
4490 " > .git",
4491 " > dir_1",
4492 " > dir_2",
4493 " .gitignore",
4494 ]
4495 );
4496
4497 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4498 .expect("dir 1 file is not ignored and should have an entry");
4499 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4500 .expect("dir 2 file is not ignored and should have an entry");
4501 let gitignored_dir_file =
4502 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4503 assert_eq!(
4504 gitignored_dir_file, None,
4505 "File in the gitignored dir should not have an entry before its dir is toggled"
4506 );
4507
4508 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4509 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4510 cx.executor().run_until_parked();
4511 assert_eq!(
4512 visible_entries_as_strings(&panel, 0..20, cx),
4513 &[
4514 "v project_root",
4515 " > .git",
4516 " v dir_1",
4517 " v gitignored_dir <== selected",
4518 " file_a.py",
4519 " file_b.py",
4520 " file_c.py",
4521 " file_1.py",
4522 " file_2.py",
4523 " file_3.py",
4524 " > dir_2",
4525 " .gitignore",
4526 ],
4527 "Should show gitignored dir file list in the project panel"
4528 );
4529 let gitignored_dir_file =
4530 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4531 .expect("after gitignored dir got opened, a file entry should be present");
4532
4533 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4534 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4535 assert_eq!(
4536 visible_entries_as_strings(&panel, 0..20, cx),
4537 &[
4538 "v project_root",
4539 " > .git",
4540 " > dir_1 <== selected",
4541 " > dir_2",
4542 " .gitignore",
4543 ],
4544 "Should hide all dir contents again and prepare for the auto reveal test"
4545 );
4546
4547 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4548 panel.update(cx, |panel, cx| {
4549 panel.project.update(cx, |_, cx| {
4550 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4551 })
4552 });
4553 cx.run_until_parked();
4554 assert_eq!(
4555 visible_entries_as_strings(&panel, 0..20, cx),
4556 &[
4557 "v project_root",
4558 " > .git",
4559 " > dir_1 <== selected",
4560 " > dir_2",
4561 " .gitignore",
4562 ],
4563 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4564 );
4565 }
4566
4567 cx.update(|_, cx| {
4568 cx.update_global::<SettingsStore, _>(|store, cx| {
4569 store.update_user_settings(cx, |settings| {
4570 settings
4571 .project_panel
4572 .get_or_insert_default()
4573 .auto_reveal_entries = Some(true)
4574 });
4575 })
4576 });
4577
4578 panel.update(cx, |panel, cx| {
4579 panel.project.update(cx, |_, cx| {
4580 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4581 })
4582 });
4583 cx.run_until_parked();
4584 assert_eq!(
4585 visible_entries_as_strings(&panel, 0..20, cx),
4586 &[
4587 "v project_root",
4588 " > .git",
4589 " v dir_1",
4590 " > gitignored_dir",
4591 " file_1.py <== selected <== marked",
4592 " file_2.py",
4593 " file_3.py",
4594 " > dir_2",
4595 " .gitignore",
4596 ],
4597 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4598 );
4599
4600 panel.update(cx, |panel, cx| {
4601 panel.project.update(cx, |_, cx| {
4602 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4603 })
4604 });
4605 cx.run_until_parked();
4606 assert_eq!(
4607 visible_entries_as_strings(&panel, 0..20, cx),
4608 &[
4609 "v project_root",
4610 " > .git",
4611 " v dir_1",
4612 " > gitignored_dir",
4613 " file_1.py",
4614 " file_2.py",
4615 " file_3.py",
4616 " v dir_2",
4617 " file_1.py <== selected <== marked",
4618 " file_2.py",
4619 " file_3.py",
4620 " .gitignore",
4621 ],
4622 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4623 );
4624
4625 panel.update(cx, |panel, cx| {
4626 panel.project.update(cx, |_, cx| {
4627 cx.emit(project::Event::ActiveEntryChanged(Some(
4628 gitignored_dir_file,
4629 )))
4630 })
4631 });
4632 cx.run_until_parked();
4633 assert_eq!(
4634 visible_entries_as_strings(&panel, 0..20, cx),
4635 &[
4636 "v project_root",
4637 " > .git",
4638 " v dir_1",
4639 " > gitignored_dir",
4640 " file_1.py",
4641 " file_2.py",
4642 " file_3.py",
4643 " v dir_2",
4644 " file_1.py <== selected <== marked",
4645 " file_2.py",
4646 " file_3.py",
4647 " .gitignore",
4648 ],
4649 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4650 );
4651
4652 panel.update(cx, |panel, cx| {
4653 panel.project.update(cx, |_, cx| {
4654 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4655 })
4656 });
4657 cx.run_until_parked();
4658 assert_eq!(
4659 visible_entries_as_strings(&panel, 0..20, cx),
4660 &[
4661 "v project_root",
4662 " > .git",
4663 " v dir_1",
4664 " v gitignored_dir",
4665 " file_a.py <== selected <== marked",
4666 " file_b.py",
4667 " file_c.py",
4668 " file_1.py",
4669 " file_2.py",
4670 " file_3.py",
4671 " v dir_2",
4672 " file_1.py",
4673 " file_2.py",
4674 " file_3.py",
4675 " .gitignore",
4676 ],
4677 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4678 );
4679}
4680
4681#[gpui::test]
4682async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
4683 init_test_with_editor(cx);
4684 cx.update(|cx| {
4685 cx.update_global::<SettingsStore, _>(|store, cx| {
4686 store.update_user_settings(cx, |settings| {
4687 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4688 settings.project.worktree.file_scan_inclusions =
4689 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
4690 settings
4691 .project_panel
4692 .get_or_insert_default()
4693 .auto_reveal_entries = Some(false)
4694 });
4695 })
4696 });
4697
4698 let fs = FakeFs::new(cx.background_executor.clone());
4699 fs.insert_tree(
4700 "/project_root",
4701 json!({
4702 ".git": {},
4703 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
4704 "dir_1": {
4705 "file_1.py": "# File 1_1 contents",
4706 "file_2.py": "# File 1_2 contents",
4707 "file_3.py": "# File 1_3 contents",
4708 "gitignored_dir": {
4709 "file_a.py": "# File contents",
4710 "file_b.py": "# File contents",
4711 "file_c.py": "# File contents",
4712 },
4713 },
4714 "dir_2": {
4715 "file_1.py": "# File 2_1 contents",
4716 "file_2.py": "# File 2_2 contents",
4717 "file_3.py": "# File 2_3 contents",
4718 },
4719 "always_included_but_ignored_dir": {
4720 "file_a.py": "# File contents",
4721 "file_b.py": "# File contents",
4722 "file_c.py": "# File contents",
4723 },
4724 }),
4725 )
4726 .await;
4727
4728 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4729 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4730 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4731 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4732 cx.run_until_parked();
4733
4734 assert_eq!(
4735 visible_entries_as_strings(&panel, 0..20, cx),
4736 &[
4737 "v project_root",
4738 " > .git",
4739 " > always_included_but_ignored_dir",
4740 " > dir_1",
4741 " > dir_2",
4742 " .gitignore",
4743 ]
4744 );
4745
4746 let gitignored_dir_file =
4747 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4748 let always_included_but_ignored_dir_file = find_project_entry(
4749 &panel,
4750 "project_root/always_included_but_ignored_dir/file_a.py",
4751 cx,
4752 )
4753 .expect("file that is .gitignored but set to always be included should have an entry");
4754 assert_eq!(
4755 gitignored_dir_file, None,
4756 "File in the gitignored dir should not have an entry unless its directory is toggled"
4757 );
4758
4759 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4760 cx.run_until_parked();
4761 cx.update(|_, cx| {
4762 cx.update_global::<SettingsStore, _>(|store, cx| {
4763 store.update_user_settings(cx, |settings| {
4764 settings
4765 .project_panel
4766 .get_or_insert_default()
4767 .auto_reveal_entries = Some(true)
4768 });
4769 })
4770 });
4771
4772 panel.update(cx, |panel, cx| {
4773 panel.project.update(cx, |_, cx| {
4774 cx.emit(project::Event::ActiveEntryChanged(Some(
4775 always_included_but_ignored_dir_file,
4776 )))
4777 })
4778 });
4779 cx.run_until_parked();
4780
4781 assert_eq!(
4782 visible_entries_as_strings(&panel, 0..20, cx),
4783 &[
4784 "v project_root",
4785 " > .git",
4786 " v always_included_but_ignored_dir",
4787 " file_a.py <== selected <== marked",
4788 " file_b.py",
4789 " file_c.py",
4790 " v dir_1",
4791 " > gitignored_dir",
4792 " file_1.py",
4793 " file_2.py",
4794 " file_3.py",
4795 " > dir_2",
4796 " .gitignore",
4797 ],
4798 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
4799 );
4800}
4801
4802#[gpui::test]
4803async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4804 init_test_with_editor(cx);
4805 cx.update(|cx| {
4806 cx.update_global::<SettingsStore, _>(|store, cx| {
4807 store.update_user_settings(cx, |settings| {
4808 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4809 settings
4810 .project_panel
4811 .get_or_insert_default()
4812 .auto_reveal_entries = Some(false)
4813 });
4814 })
4815 });
4816
4817 let fs = FakeFs::new(cx.background_executor.clone());
4818 fs.insert_tree(
4819 "/project_root",
4820 json!({
4821 ".git": {},
4822 ".gitignore": "**/gitignored_dir",
4823 "dir_1": {
4824 "file_1.py": "# File 1_1 contents",
4825 "file_2.py": "# File 1_2 contents",
4826 "file_3.py": "# File 1_3 contents",
4827 "gitignored_dir": {
4828 "file_a.py": "# File contents",
4829 "file_b.py": "# File contents",
4830 "file_c.py": "# File contents",
4831 },
4832 },
4833 "dir_2": {
4834 "file_1.py": "# File 2_1 contents",
4835 "file_2.py": "# File 2_2 contents",
4836 "file_3.py": "# File 2_3 contents",
4837 }
4838 }),
4839 )
4840 .await;
4841
4842 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4843 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4844 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4845 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4846 cx.run_until_parked();
4847
4848 assert_eq!(
4849 visible_entries_as_strings(&panel, 0..20, cx),
4850 &[
4851 "v project_root",
4852 " > .git",
4853 " > dir_1",
4854 " > dir_2",
4855 " .gitignore",
4856 ]
4857 );
4858
4859 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4860 .expect("dir 1 file is not ignored and should have an entry");
4861 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4862 .expect("dir 2 file is not ignored and should have an entry");
4863 let gitignored_dir_file =
4864 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4865 assert_eq!(
4866 gitignored_dir_file, None,
4867 "File in the gitignored dir should not have an entry before its dir is toggled"
4868 );
4869
4870 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4871 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4872 cx.run_until_parked();
4873 assert_eq!(
4874 visible_entries_as_strings(&panel, 0..20, cx),
4875 &[
4876 "v project_root",
4877 " > .git",
4878 " v dir_1",
4879 " v gitignored_dir <== selected",
4880 " file_a.py",
4881 " file_b.py",
4882 " file_c.py",
4883 " file_1.py",
4884 " file_2.py",
4885 " file_3.py",
4886 " > dir_2",
4887 " .gitignore",
4888 ],
4889 "Should show gitignored dir file list in the project panel"
4890 );
4891 let gitignored_dir_file =
4892 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4893 .expect("after gitignored dir got opened, a file entry should be present");
4894
4895 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4896 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4897 assert_eq!(
4898 visible_entries_as_strings(&panel, 0..20, cx),
4899 &[
4900 "v project_root",
4901 " > .git",
4902 " > dir_1 <== selected",
4903 " > dir_2",
4904 " .gitignore",
4905 ],
4906 "Should hide all dir contents again and prepare for the explicit reveal test"
4907 );
4908
4909 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4910 panel.update(cx, |panel, cx| {
4911 panel.project.update(cx, |_, cx| {
4912 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4913 })
4914 });
4915 cx.run_until_parked();
4916 assert_eq!(
4917 visible_entries_as_strings(&panel, 0..20, cx),
4918 &[
4919 "v project_root",
4920 " > .git",
4921 " > dir_1 <== selected",
4922 " > dir_2",
4923 " .gitignore",
4924 ],
4925 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4926 );
4927 }
4928
4929 panel.update(cx, |panel, cx| {
4930 panel.project.update(cx, |_, cx| {
4931 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4932 })
4933 });
4934 cx.run_until_parked();
4935 assert_eq!(
4936 visible_entries_as_strings(&panel, 0..20, cx),
4937 &[
4938 "v project_root",
4939 " > .git",
4940 " v dir_1",
4941 " > gitignored_dir",
4942 " file_1.py <== selected <== marked",
4943 " file_2.py",
4944 " file_3.py",
4945 " > dir_2",
4946 " .gitignore",
4947 ],
4948 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4949 );
4950
4951 panel.update(cx, |panel, cx| {
4952 panel.project.update(cx, |_, cx| {
4953 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4954 })
4955 });
4956 cx.run_until_parked();
4957 assert_eq!(
4958 visible_entries_as_strings(&panel, 0..20, cx),
4959 &[
4960 "v project_root",
4961 " > .git",
4962 " v dir_1",
4963 " > gitignored_dir",
4964 " file_1.py",
4965 " file_2.py",
4966 " file_3.py",
4967 " v dir_2",
4968 " file_1.py <== selected <== marked",
4969 " file_2.py",
4970 " file_3.py",
4971 " .gitignore",
4972 ],
4973 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4974 );
4975
4976 panel.update(cx, |panel, cx| {
4977 panel.project.update(cx, |_, cx| {
4978 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4979 })
4980 });
4981 cx.run_until_parked();
4982 assert_eq!(
4983 visible_entries_as_strings(&panel, 0..20, cx),
4984 &[
4985 "v project_root",
4986 " > .git",
4987 " v dir_1",
4988 " v gitignored_dir",
4989 " file_a.py <== selected <== marked",
4990 " file_b.py",
4991 " file_c.py",
4992 " file_1.py",
4993 " file_2.py",
4994 " file_3.py",
4995 " v dir_2",
4996 " file_1.py",
4997 " file_2.py",
4998 " file_3.py",
4999 " .gitignore",
5000 ],
5001 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5002 );
5003}
5004
5005#[gpui::test]
5006async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5007 init_test(cx);
5008 cx.update(|cx| {
5009 cx.update_global::<SettingsStore, _>(|store, cx| {
5010 store.update_user_settings(cx, |settings| {
5011 settings.project.worktree.file_scan_exclusions =
5012 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5013 });
5014 });
5015 });
5016
5017 cx.update(|cx| {
5018 register_project_item::<TestProjectItemView>(cx);
5019 });
5020
5021 let fs = FakeFs::new(cx.executor());
5022 fs.insert_tree(
5023 "/root1",
5024 json!({
5025 ".dockerignore": "",
5026 ".git": {
5027 "HEAD": "",
5028 },
5029 }),
5030 )
5031 .await;
5032
5033 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5034 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5035 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5036 let panel = workspace
5037 .update(cx, |workspace, window, cx| {
5038 let panel = ProjectPanel::new(workspace, window, cx);
5039 workspace.add_panel(panel.clone(), window, cx);
5040 panel
5041 })
5042 .unwrap();
5043 cx.run_until_parked();
5044
5045 select_path(&panel, "root1", cx);
5046 assert_eq!(
5047 visible_entries_as_strings(&panel, 0..10, cx),
5048 &["v root1 <== selected", " .dockerignore",]
5049 );
5050 workspace
5051 .update(cx, |workspace, _, cx| {
5052 assert!(
5053 workspace.active_item(cx).is_none(),
5054 "Should have no active items in the beginning"
5055 );
5056 })
5057 .unwrap();
5058
5059 let excluded_file_path = ".git/COMMIT_EDITMSG";
5060 let excluded_dir_path = "excluded_dir";
5061
5062 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5063 cx.run_until_parked();
5064 panel.update_in(cx, |panel, window, cx| {
5065 assert!(panel.filename_editor.read(cx).is_focused(window));
5066 });
5067 panel
5068 .update_in(cx, |panel, window, cx| {
5069 panel.filename_editor.update(cx, |editor, cx| {
5070 editor.set_text(excluded_file_path, window, cx)
5071 });
5072 panel.confirm_edit(true, window, cx).unwrap()
5073 })
5074 .await
5075 .unwrap();
5076
5077 assert_eq!(
5078 visible_entries_as_strings(&panel, 0..13, cx),
5079 &["v root1", " .dockerignore"],
5080 "Excluded dir should not be shown after opening a file in it"
5081 );
5082 panel.update_in(cx, |panel, window, cx| {
5083 assert!(
5084 !panel.filename_editor.read(cx).is_focused(window),
5085 "Should have closed the file name editor"
5086 );
5087 });
5088 workspace
5089 .update(cx, |workspace, _, cx| {
5090 let active_entry_path = workspace
5091 .active_item(cx)
5092 .expect("should have opened and activated the excluded item")
5093 .act_as::<TestProjectItemView>(cx)
5094 .expect("should have opened the corresponding project item for the excluded item")
5095 .read(cx)
5096 .path
5097 .clone();
5098 assert_eq!(
5099 active_entry_path.path.as_ref(),
5100 rel_path(excluded_file_path),
5101 "Should open the excluded file"
5102 );
5103
5104 assert!(
5105 workspace.notification_ids().is_empty(),
5106 "Should have no notifications after opening an excluded file"
5107 );
5108 })
5109 .unwrap();
5110 assert!(
5111 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5112 "Should have created the excluded file"
5113 );
5114
5115 select_path(&panel, "root1", cx);
5116 panel.update_in(cx, |panel, window, cx| {
5117 panel.new_directory(&NewDirectory, window, cx)
5118 });
5119 cx.run_until_parked();
5120 panel.update_in(cx, |panel, window, cx| {
5121 assert!(panel.filename_editor.read(cx).is_focused(window));
5122 });
5123 panel
5124 .update_in(cx, |panel, window, cx| {
5125 panel.filename_editor.update(cx, |editor, cx| {
5126 editor.set_text(excluded_file_path, window, cx)
5127 });
5128 panel.confirm_edit(true, window, cx).unwrap()
5129 })
5130 .await
5131 .unwrap();
5132 cx.run_until_parked();
5133 assert_eq!(
5134 visible_entries_as_strings(&panel, 0..13, cx),
5135 &["v root1", " .dockerignore"],
5136 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5137 );
5138 panel.update_in(cx, |panel, window, cx| {
5139 assert!(
5140 !panel.filename_editor.read(cx).is_focused(window),
5141 "Should have closed the file name editor"
5142 );
5143 });
5144 workspace
5145 .update(cx, |workspace, _, cx| {
5146 let notifications = workspace.notification_ids();
5147 assert_eq!(
5148 notifications.len(),
5149 1,
5150 "Should receive one notification with the error message"
5151 );
5152 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5153 assert!(workspace.notification_ids().is_empty());
5154 })
5155 .unwrap();
5156
5157 select_path(&panel, "root1", cx);
5158 panel.update_in(cx, |panel, window, cx| {
5159 panel.new_directory(&NewDirectory, window, cx)
5160 });
5161 cx.run_until_parked();
5162
5163 panel.update_in(cx, |panel, window, cx| {
5164 assert!(panel.filename_editor.read(cx).is_focused(window));
5165 });
5166
5167 panel
5168 .update_in(cx, |panel, window, cx| {
5169 panel.filename_editor.update(cx, |editor, cx| {
5170 editor.set_text(excluded_dir_path, window, cx)
5171 });
5172 panel.confirm_edit(true, window, cx).unwrap()
5173 })
5174 .await
5175 .unwrap();
5176
5177 cx.run_until_parked();
5178
5179 assert_eq!(
5180 visible_entries_as_strings(&panel, 0..13, cx),
5181 &["v root1", " .dockerignore"],
5182 "Should not change the project panel after trying to create an excluded directory"
5183 );
5184 panel.update_in(cx, |panel, window, cx| {
5185 assert!(
5186 !panel.filename_editor.read(cx).is_focused(window),
5187 "Should have closed the file name editor"
5188 );
5189 });
5190 workspace
5191 .update(cx, |workspace, _, cx| {
5192 let notifications = workspace.notification_ids();
5193 assert_eq!(
5194 notifications.len(),
5195 1,
5196 "Should receive one notification explaining that no directory is actually shown"
5197 );
5198 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5199 assert!(workspace.notification_ids().is_empty());
5200 })
5201 .unwrap();
5202 assert!(
5203 fs.is_dir(Path::new("/root1/excluded_dir")).await,
5204 "Should have created the excluded directory"
5205 );
5206}
5207
5208#[gpui::test]
5209async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5210 init_test_with_editor(cx);
5211
5212 let fs = FakeFs::new(cx.executor());
5213 fs.insert_tree(
5214 "/src",
5215 json!({
5216 "test": {
5217 "first.rs": "// First Rust file",
5218 "second.rs": "// Second Rust file",
5219 "third.rs": "// Third Rust file",
5220 }
5221 }),
5222 )
5223 .await;
5224
5225 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5226 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5227 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5228 let panel = workspace
5229 .update(cx, |workspace, window, cx| {
5230 let panel = ProjectPanel::new(workspace, window, cx);
5231 workspace.add_panel(panel.clone(), window, cx);
5232 panel
5233 })
5234 .unwrap();
5235 cx.run_until_parked();
5236
5237 select_path(&panel, "src", cx);
5238 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5239 cx.executor().run_until_parked();
5240 assert_eq!(
5241 visible_entries_as_strings(&panel, 0..10, cx),
5242 &[
5243 //
5244 "v src <== selected",
5245 " > test"
5246 ]
5247 );
5248 panel.update_in(cx, |panel, window, cx| {
5249 panel.new_directory(&NewDirectory, window, cx)
5250 });
5251 cx.executor().run_until_parked();
5252 panel.update_in(cx, |panel, window, cx| {
5253 assert!(panel.filename_editor.read(cx).is_focused(window));
5254 });
5255 assert_eq!(
5256 visible_entries_as_strings(&panel, 0..10, cx),
5257 &[
5258 //
5259 "v src",
5260 " > [EDITOR: ''] <== selected",
5261 " > test"
5262 ]
5263 );
5264
5265 panel.update_in(cx, |panel, window, cx| {
5266 panel.cancel(&menu::Cancel, window, cx);
5267 panel.update_visible_entries(None, false, false, window, cx);
5268 });
5269 cx.executor().run_until_parked();
5270 assert_eq!(
5271 visible_entries_as_strings(&panel, 0..10, cx),
5272 &[
5273 //
5274 "v src <== selected",
5275 " > test"
5276 ]
5277 );
5278}
5279
5280#[gpui::test]
5281async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5282 init_test_with_editor(cx);
5283
5284 let fs = FakeFs::new(cx.executor());
5285 fs.insert_tree(
5286 "/root",
5287 json!({
5288 "dir1": {
5289 "subdir1": {},
5290 "file1.txt": "",
5291 "file2.txt": "",
5292 },
5293 "dir2": {
5294 "subdir2": {},
5295 "file3.txt": "",
5296 "file4.txt": "",
5297 },
5298 "file5.txt": "",
5299 "file6.txt": "",
5300 }),
5301 )
5302 .await;
5303
5304 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5305 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5306 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5307 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5308 cx.run_until_parked();
5309
5310 toggle_expand_dir(&panel, "root/dir1", cx);
5311 toggle_expand_dir(&panel, "root/dir2", cx);
5312
5313 // Test Case 1: Delete middle file in directory
5314 select_path(&panel, "root/dir1/file1.txt", cx);
5315 assert_eq!(
5316 visible_entries_as_strings(&panel, 0..15, cx),
5317 &[
5318 "v root",
5319 " v dir1",
5320 " > subdir1",
5321 " file1.txt <== selected",
5322 " file2.txt",
5323 " v dir2",
5324 " > subdir2",
5325 " file3.txt",
5326 " file4.txt",
5327 " file5.txt",
5328 " file6.txt",
5329 ],
5330 "Initial state before deleting middle file"
5331 );
5332
5333 submit_deletion(&panel, cx);
5334 assert_eq!(
5335 visible_entries_as_strings(&panel, 0..15, cx),
5336 &[
5337 "v root",
5338 " v dir1",
5339 " > subdir1",
5340 " file2.txt <== selected",
5341 " v dir2",
5342 " > subdir2",
5343 " file3.txt",
5344 " file4.txt",
5345 " file5.txt",
5346 " file6.txt",
5347 ],
5348 "Should select next file after deleting middle file"
5349 );
5350
5351 // Test Case 2: Delete last file in directory
5352 submit_deletion(&panel, cx);
5353 assert_eq!(
5354 visible_entries_as_strings(&panel, 0..15, cx),
5355 &[
5356 "v root",
5357 " v dir1",
5358 " > subdir1 <== selected",
5359 " v dir2",
5360 " > subdir2",
5361 " file3.txt",
5362 " file4.txt",
5363 " file5.txt",
5364 " file6.txt",
5365 ],
5366 "Should select next directory when last file is deleted"
5367 );
5368
5369 // Test Case 3: Delete root level file
5370 select_path(&panel, "root/file6.txt", cx);
5371 assert_eq!(
5372 visible_entries_as_strings(&panel, 0..15, cx),
5373 &[
5374 "v root",
5375 " v dir1",
5376 " > subdir1",
5377 " v dir2",
5378 " > subdir2",
5379 " file3.txt",
5380 " file4.txt",
5381 " file5.txt",
5382 " file6.txt <== selected",
5383 ],
5384 "Initial state before deleting root level file"
5385 );
5386
5387 submit_deletion(&panel, cx);
5388 assert_eq!(
5389 visible_entries_as_strings(&panel, 0..15, cx),
5390 &[
5391 "v root",
5392 " v dir1",
5393 " > subdir1",
5394 " v dir2",
5395 " > subdir2",
5396 " file3.txt",
5397 " file4.txt",
5398 " file5.txt <== selected",
5399 ],
5400 "Should select prev entry at root level"
5401 );
5402}
5403
5404#[gpui::test]
5405async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
5406 init_test_with_editor(cx);
5407
5408 let fs = FakeFs::new(cx.executor());
5409 fs.insert_tree(
5410 path!("/root"),
5411 json!({
5412 "aa": "// Testing 1",
5413 "bb": "// Testing 2",
5414 "cc": "// Testing 3",
5415 "dd": "// Testing 4",
5416 "ee": "// Testing 5",
5417 "ff": "// Testing 6",
5418 "gg": "// Testing 7",
5419 "hh": "// Testing 8",
5420 "ii": "// Testing 8",
5421 ".gitignore": "bb\ndd\nee\nff\nii\n'",
5422 }),
5423 )
5424 .await;
5425
5426 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5427 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5428 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5429
5430 // Test 1: Auto selection with one gitignored file next to the deleted file
5431 cx.update(|_, cx| {
5432 let settings = *ProjectPanelSettings::get_global(cx);
5433 ProjectPanelSettings::override_global(
5434 ProjectPanelSettings {
5435 hide_gitignore: true,
5436 ..settings
5437 },
5438 cx,
5439 );
5440 });
5441
5442 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5443 cx.run_until_parked();
5444
5445 select_path(&panel, "root/aa", cx);
5446 assert_eq!(
5447 visible_entries_as_strings(&panel, 0..10, cx),
5448 &[
5449 "v root",
5450 " .gitignore",
5451 " aa <== selected",
5452 " cc",
5453 " gg",
5454 " hh"
5455 ],
5456 "Initial state should hide files on .gitignore"
5457 );
5458
5459 submit_deletion(&panel, cx);
5460
5461 assert_eq!(
5462 visible_entries_as_strings(&panel, 0..10, cx),
5463 &[
5464 "v root",
5465 " .gitignore",
5466 " cc <== selected",
5467 " gg",
5468 " hh"
5469 ],
5470 "Should select next entry not on .gitignore"
5471 );
5472
5473 // Test 2: Auto selection with many gitignored files next to the deleted file
5474 submit_deletion(&panel, cx);
5475 assert_eq!(
5476 visible_entries_as_strings(&panel, 0..10, cx),
5477 &[
5478 "v root",
5479 " .gitignore",
5480 " gg <== selected",
5481 " hh"
5482 ],
5483 "Should select next entry not on .gitignore"
5484 );
5485
5486 // Test 3: Auto selection of entry before deleted file
5487 select_path(&panel, "root/hh", cx);
5488 assert_eq!(
5489 visible_entries_as_strings(&panel, 0..10, cx),
5490 &[
5491 "v root",
5492 " .gitignore",
5493 " gg",
5494 " hh <== selected"
5495 ],
5496 "Should select next entry not on .gitignore"
5497 );
5498 submit_deletion(&panel, cx);
5499 assert_eq!(
5500 visible_entries_as_strings(&panel, 0..10, cx),
5501 &["v root", " .gitignore", " gg <== selected"],
5502 "Should select next entry not on .gitignore"
5503 );
5504}
5505
5506#[gpui::test]
5507async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
5508 init_test_with_editor(cx);
5509
5510 let fs = FakeFs::new(cx.executor());
5511 fs.insert_tree(
5512 path!("/root"),
5513 json!({
5514 "dir1": {
5515 "file1": "// Testing",
5516 "file2": "// Testing",
5517 "file3": "// Testing"
5518 },
5519 "aa": "// Testing",
5520 ".gitignore": "file1\nfile3\n",
5521 }),
5522 )
5523 .await;
5524
5525 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5526 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5527 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5528
5529 cx.update(|_, cx| {
5530 let settings = *ProjectPanelSettings::get_global(cx);
5531 ProjectPanelSettings::override_global(
5532 ProjectPanelSettings {
5533 hide_gitignore: true,
5534 ..settings
5535 },
5536 cx,
5537 );
5538 });
5539
5540 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5541 cx.run_until_parked();
5542
5543 // Test 1: Visible items should exclude files on gitignore
5544 toggle_expand_dir(&panel, "root/dir1", cx);
5545 select_path(&panel, "root/dir1/file2", cx);
5546 assert_eq!(
5547 visible_entries_as_strings(&panel, 0..10, cx),
5548 &[
5549 "v root",
5550 " v dir1",
5551 " file2 <== selected",
5552 " .gitignore",
5553 " aa"
5554 ],
5555 "Initial state should hide files on .gitignore"
5556 );
5557 submit_deletion(&panel, cx);
5558
5559 // Test 2: Auto selection should go to the parent
5560 assert_eq!(
5561 visible_entries_as_strings(&panel, 0..10, cx),
5562 &[
5563 "v root",
5564 " v dir1 <== selected",
5565 " .gitignore",
5566 " aa"
5567 ],
5568 "Initial state should hide files on .gitignore"
5569 );
5570}
5571
5572#[gpui::test]
5573async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
5574 init_test_with_editor(cx);
5575
5576 let fs = FakeFs::new(cx.executor());
5577 fs.insert_tree(
5578 "/root",
5579 json!({
5580 "dir1": {
5581 "subdir1": {
5582 "a.txt": "",
5583 "b.txt": ""
5584 },
5585 "file1.txt": "",
5586 },
5587 "dir2": {
5588 "subdir2": {
5589 "c.txt": "",
5590 "d.txt": ""
5591 },
5592 "file2.txt": "",
5593 },
5594 "file3.txt": "",
5595 }),
5596 )
5597 .await;
5598
5599 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5600 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5601 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5602 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5603 cx.run_until_parked();
5604
5605 toggle_expand_dir(&panel, "root/dir1", cx);
5606 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5607 toggle_expand_dir(&panel, "root/dir2", cx);
5608 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5609
5610 // Test Case 1: Select and delete nested directory with parent
5611 cx.simulate_modifiers_change(gpui::Modifiers {
5612 control: true,
5613 ..Default::default()
5614 });
5615 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5616 select_path_with_mark(&panel, "root/dir1", cx);
5617
5618 assert_eq!(
5619 visible_entries_as_strings(&panel, 0..15, cx),
5620 &[
5621 "v root",
5622 " v dir1 <== selected <== marked",
5623 " v subdir1 <== marked",
5624 " a.txt",
5625 " b.txt",
5626 " file1.txt",
5627 " v dir2",
5628 " v subdir2",
5629 " c.txt",
5630 " d.txt",
5631 " file2.txt",
5632 " file3.txt",
5633 ],
5634 "Initial state before deleting nested directory with parent"
5635 );
5636
5637 submit_deletion(&panel, cx);
5638 assert_eq!(
5639 visible_entries_as_strings(&panel, 0..15, cx),
5640 &[
5641 "v root",
5642 " v dir2 <== selected",
5643 " v subdir2",
5644 " c.txt",
5645 " d.txt",
5646 " file2.txt",
5647 " file3.txt",
5648 ],
5649 "Should select next directory after deleting directory with parent"
5650 );
5651
5652 // Test Case 2: Select mixed files and directories across levels
5653 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
5654 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
5655 select_path_with_mark(&panel, "root/file3.txt", cx);
5656
5657 assert_eq!(
5658 visible_entries_as_strings(&panel, 0..15, cx),
5659 &[
5660 "v root",
5661 " v dir2",
5662 " v subdir2",
5663 " c.txt <== marked",
5664 " d.txt",
5665 " file2.txt <== marked",
5666 " file3.txt <== selected <== marked",
5667 ],
5668 "Initial state before deleting"
5669 );
5670
5671 submit_deletion(&panel, cx);
5672 assert_eq!(
5673 visible_entries_as_strings(&panel, 0..15, cx),
5674 &[
5675 "v root",
5676 " v dir2 <== selected",
5677 " v subdir2",
5678 " d.txt",
5679 ],
5680 "Should select sibling directory"
5681 );
5682}
5683
5684#[gpui::test]
5685async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
5686 init_test_with_editor(cx);
5687
5688 let fs = FakeFs::new(cx.executor());
5689 fs.insert_tree(
5690 "/root",
5691 json!({
5692 "dir1": {
5693 "subdir1": {
5694 "a.txt": "",
5695 "b.txt": ""
5696 },
5697 "file1.txt": "",
5698 },
5699 "dir2": {
5700 "subdir2": {
5701 "c.txt": "",
5702 "d.txt": ""
5703 },
5704 "file2.txt": "",
5705 },
5706 "file3.txt": "",
5707 "file4.txt": "",
5708 }),
5709 )
5710 .await;
5711
5712 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5713 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5714 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5715 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5716 cx.run_until_parked();
5717
5718 toggle_expand_dir(&panel, "root/dir1", cx);
5719 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5720 toggle_expand_dir(&panel, "root/dir2", cx);
5721 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5722
5723 // Test Case 1: Select all root files and directories
5724 cx.simulate_modifiers_change(gpui::Modifiers {
5725 control: true,
5726 ..Default::default()
5727 });
5728 select_path_with_mark(&panel, "root/dir1", cx);
5729 select_path_with_mark(&panel, "root/dir2", cx);
5730 select_path_with_mark(&panel, "root/file3.txt", cx);
5731 select_path_with_mark(&panel, "root/file4.txt", cx);
5732 assert_eq!(
5733 visible_entries_as_strings(&panel, 0..20, cx),
5734 &[
5735 "v root",
5736 " v dir1 <== marked",
5737 " v subdir1",
5738 " a.txt",
5739 " b.txt",
5740 " file1.txt",
5741 " v dir2 <== marked",
5742 " v subdir2",
5743 " c.txt",
5744 " d.txt",
5745 " file2.txt",
5746 " file3.txt <== marked",
5747 " file4.txt <== selected <== marked",
5748 ],
5749 "State before deleting all contents"
5750 );
5751
5752 submit_deletion(&panel, cx);
5753 assert_eq!(
5754 visible_entries_as_strings(&panel, 0..20, cx),
5755 &["v root <== selected"],
5756 "Only empty root directory should remain after deleting all contents"
5757 );
5758}
5759
5760#[gpui::test]
5761async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
5762 init_test_with_editor(cx);
5763
5764 let fs = FakeFs::new(cx.executor());
5765 fs.insert_tree(
5766 "/root",
5767 json!({
5768 "dir1": {
5769 "subdir1": {
5770 "file_a.txt": "content a",
5771 "file_b.txt": "content b",
5772 },
5773 "subdir2": {
5774 "file_c.txt": "content c",
5775 },
5776 "file1.txt": "content 1",
5777 },
5778 "dir2": {
5779 "file2.txt": "content 2",
5780 },
5781 }),
5782 )
5783 .await;
5784
5785 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5786 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5787 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5788 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5789 cx.run_until_parked();
5790
5791 toggle_expand_dir(&panel, "root/dir1", cx);
5792 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5793 toggle_expand_dir(&panel, "root/dir2", cx);
5794 cx.simulate_modifiers_change(gpui::Modifiers {
5795 control: true,
5796 ..Default::default()
5797 });
5798
5799 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
5800 select_path_with_mark(&panel, "root/dir1", cx);
5801 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5802 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
5803
5804 assert_eq!(
5805 visible_entries_as_strings(&panel, 0..20, cx),
5806 &[
5807 "v root",
5808 " v dir1 <== marked",
5809 " v subdir1 <== marked",
5810 " file_a.txt <== selected <== marked",
5811 " file_b.txt",
5812 " > subdir2",
5813 " file1.txt",
5814 " v dir2",
5815 " file2.txt",
5816 ],
5817 "State with parent dir, subdir, and file selected"
5818 );
5819 submit_deletion(&panel, cx);
5820 assert_eq!(
5821 visible_entries_as_strings(&panel, 0..20, cx),
5822 &["v root", " v dir2 <== selected", " file2.txt",],
5823 "Only dir2 should remain after deletion"
5824 );
5825}
5826
5827#[gpui::test]
5828async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
5829 init_test_with_editor(cx);
5830
5831 let fs = FakeFs::new(cx.executor());
5832 // First worktree
5833 fs.insert_tree(
5834 "/root1",
5835 json!({
5836 "dir1": {
5837 "file1.txt": "content 1",
5838 "file2.txt": "content 2",
5839 },
5840 "dir2": {
5841 "file3.txt": "content 3",
5842 },
5843 }),
5844 )
5845 .await;
5846
5847 // Second worktree
5848 fs.insert_tree(
5849 "/root2",
5850 json!({
5851 "dir3": {
5852 "file4.txt": "content 4",
5853 "file5.txt": "content 5",
5854 },
5855 "file6.txt": "content 6",
5856 }),
5857 )
5858 .await;
5859
5860 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5861 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5862 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5863 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5864 cx.run_until_parked();
5865
5866 // Expand all directories for testing
5867 toggle_expand_dir(&panel, "root1/dir1", cx);
5868 toggle_expand_dir(&panel, "root1/dir2", cx);
5869 toggle_expand_dir(&panel, "root2/dir3", cx);
5870
5871 // Test Case 1: Delete files across different worktrees
5872 cx.simulate_modifiers_change(gpui::Modifiers {
5873 control: true,
5874 ..Default::default()
5875 });
5876 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
5877 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
5878
5879 assert_eq!(
5880 visible_entries_as_strings(&panel, 0..20, cx),
5881 &[
5882 "v root1",
5883 " v dir1",
5884 " file1.txt <== marked",
5885 " file2.txt",
5886 " v dir2",
5887 " file3.txt",
5888 "v root2",
5889 " v dir3",
5890 " file4.txt <== selected <== marked",
5891 " file5.txt",
5892 " file6.txt",
5893 ],
5894 "Initial state with files selected from different worktrees"
5895 );
5896
5897 submit_deletion(&panel, cx);
5898 assert_eq!(
5899 visible_entries_as_strings(&panel, 0..20, cx),
5900 &[
5901 "v root1",
5902 " v dir1",
5903 " file2.txt",
5904 " v dir2",
5905 " file3.txt",
5906 "v root2",
5907 " v dir3",
5908 " file5.txt <== selected",
5909 " file6.txt",
5910 ],
5911 "Should select next file in the last worktree after deletion"
5912 );
5913
5914 // Test Case 2: Delete directories from different worktrees
5915 select_path_with_mark(&panel, "root1/dir1", cx);
5916 select_path_with_mark(&panel, "root2/dir3", cx);
5917
5918 assert_eq!(
5919 visible_entries_as_strings(&panel, 0..20, cx),
5920 &[
5921 "v root1",
5922 " v dir1 <== marked",
5923 " file2.txt",
5924 " v dir2",
5925 " file3.txt",
5926 "v root2",
5927 " v dir3 <== selected <== marked",
5928 " file5.txt",
5929 " file6.txt",
5930 ],
5931 "State with directories marked from different worktrees"
5932 );
5933
5934 submit_deletion(&panel, cx);
5935 assert_eq!(
5936 visible_entries_as_strings(&panel, 0..20, cx),
5937 &[
5938 "v root1",
5939 " v dir2",
5940 " file3.txt",
5941 "v root2",
5942 " file6.txt <== selected",
5943 ],
5944 "Should select remaining file in last worktree after directory deletion"
5945 );
5946
5947 // Test Case 4: Delete all remaining files except roots
5948 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
5949 select_path_with_mark(&panel, "root2/file6.txt", cx);
5950
5951 assert_eq!(
5952 visible_entries_as_strings(&panel, 0..20, cx),
5953 &[
5954 "v root1",
5955 " v dir2",
5956 " file3.txt <== marked",
5957 "v root2",
5958 " file6.txt <== selected <== marked",
5959 ],
5960 "State with all remaining files marked"
5961 );
5962
5963 submit_deletion(&panel, cx);
5964 assert_eq!(
5965 visible_entries_as_strings(&panel, 0..20, cx),
5966 &["v root1", " v dir2", "v root2 <== selected"],
5967 "Second parent root should be selected after deleting"
5968 );
5969}
5970
5971#[gpui::test]
5972async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
5973 init_test_with_editor(cx);
5974
5975 let fs = FakeFs::new(cx.executor());
5976 fs.insert_tree(
5977 "/root",
5978 json!({
5979 "dir1": {
5980 "file1.txt": "",
5981 "file2.txt": "",
5982 "file3.txt": "",
5983 },
5984 "dir2": {
5985 "file4.txt": "",
5986 "file5.txt": "",
5987 },
5988 }),
5989 )
5990 .await;
5991
5992 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5993 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5994 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5995 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5996 cx.run_until_parked();
5997
5998 toggle_expand_dir(&panel, "root/dir1", cx);
5999 toggle_expand_dir(&panel, "root/dir2", cx);
6000
6001 cx.simulate_modifiers_change(gpui::Modifiers {
6002 control: true,
6003 ..Default::default()
6004 });
6005
6006 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6007 select_path(&panel, "root/dir1/file1.txt", cx);
6008
6009 assert_eq!(
6010 visible_entries_as_strings(&panel, 0..15, cx),
6011 &[
6012 "v root",
6013 " v dir1",
6014 " file1.txt <== selected",
6015 " file2.txt <== marked",
6016 " file3.txt",
6017 " v dir2",
6018 " file4.txt",
6019 " file5.txt",
6020 ],
6021 "Initial state with one marked entry and different selection"
6022 );
6023
6024 // Delete should operate on the selected entry (file1.txt)
6025 submit_deletion(&panel, cx);
6026 assert_eq!(
6027 visible_entries_as_strings(&panel, 0..15, cx),
6028 &[
6029 "v root",
6030 " v dir1",
6031 " file2.txt <== selected <== marked",
6032 " file3.txt",
6033 " v dir2",
6034 " file4.txt",
6035 " file5.txt",
6036 ],
6037 "Should delete selected file, not marked file"
6038 );
6039
6040 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6041 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6042 select_path(&panel, "root/dir2/file5.txt", cx);
6043
6044 assert_eq!(
6045 visible_entries_as_strings(&panel, 0..15, cx),
6046 &[
6047 "v root",
6048 " v dir1",
6049 " file2.txt <== marked",
6050 " file3.txt <== marked",
6051 " v dir2",
6052 " file4.txt <== marked",
6053 " file5.txt <== selected",
6054 ],
6055 "Initial state with multiple marked entries and different selection"
6056 );
6057
6058 // Delete should operate on all marked entries, ignoring the selection
6059 submit_deletion(&panel, cx);
6060 assert_eq!(
6061 visible_entries_as_strings(&panel, 0..15, cx),
6062 &[
6063 "v root",
6064 " v dir1",
6065 " v dir2",
6066 " file5.txt <== selected",
6067 ],
6068 "Should delete all marked files, leaving only the selected file"
6069 );
6070}
6071
6072#[gpui::test]
6073async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6074 init_test_with_editor(cx);
6075
6076 let fs = FakeFs::new(cx.executor());
6077 fs.insert_tree(
6078 "/root_b",
6079 json!({
6080 "dir1": {
6081 "file1.txt": "content 1",
6082 "file2.txt": "content 2",
6083 },
6084 }),
6085 )
6086 .await;
6087
6088 fs.insert_tree(
6089 "/root_c",
6090 json!({
6091 "dir2": {},
6092 }),
6093 )
6094 .await;
6095
6096 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6097 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6098 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6099 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6100 cx.run_until_parked();
6101
6102 toggle_expand_dir(&panel, "root_b/dir1", cx);
6103 toggle_expand_dir(&panel, "root_c/dir2", cx);
6104
6105 cx.simulate_modifiers_change(gpui::Modifiers {
6106 control: true,
6107 ..Default::default()
6108 });
6109 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6110 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6111
6112 assert_eq!(
6113 visible_entries_as_strings(&panel, 0..20, cx),
6114 &[
6115 "v root_b",
6116 " v dir1",
6117 " file1.txt <== marked",
6118 " file2.txt <== selected <== marked",
6119 "v root_c",
6120 " v dir2",
6121 ],
6122 "Initial state with files marked in root_b"
6123 );
6124
6125 submit_deletion(&panel, cx);
6126 assert_eq!(
6127 visible_entries_as_strings(&panel, 0..20, cx),
6128 &[
6129 "v root_b",
6130 " v dir1 <== selected",
6131 "v root_c",
6132 " v dir2",
6133 ],
6134 "After deletion in root_b as it's last deletion, selection should be in root_b"
6135 );
6136
6137 select_path_with_mark(&panel, "root_c/dir2", cx);
6138
6139 submit_deletion(&panel, cx);
6140 assert_eq!(
6141 visible_entries_as_strings(&panel, 0..20, cx),
6142 &["v root_b", " v dir1", "v root_c <== selected",],
6143 "After deleting from root_c, it should remain in root_c"
6144 );
6145}
6146
6147fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6148 let path = rel_path(path);
6149 panel.update_in(cx, |panel, window, cx| {
6150 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6151 let worktree = worktree.read(cx);
6152 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6153 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6154 panel.toggle_expanded(entry_id, window, cx);
6155 return;
6156 }
6157 }
6158 panic!("no worktree for path {:?}", path);
6159 });
6160 cx.run_until_parked();
6161}
6162
6163#[gpui::test]
6164async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6165 init_test_with_editor(cx);
6166
6167 let fs = FakeFs::new(cx.executor());
6168 fs.insert_tree(
6169 path!("/root"),
6170 json!({
6171 ".gitignore": "**/ignored_dir\n**/ignored_nested",
6172 "dir1": {
6173 "empty1": {
6174 "empty2": {
6175 "empty3": {
6176 "file.txt": ""
6177 }
6178 }
6179 },
6180 "subdir1": {
6181 "file1.txt": "",
6182 "file2.txt": "",
6183 "ignored_nested": {
6184 "ignored_file.txt": ""
6185 }
6186 },
6187 "ignored_dir": {
6188 "subdir": {
6189 "deep_file.txt": ""
6190 }
6191 }
6192 }
6193 }),
6194 )
6195 .await;
6196
6197 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6198 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6199 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6200
6201 // Test 1: When auto-fold is enabled
6202 cx.update(|_, cx| {
6203 let settings = *ProjectPanelSettings::get_global(cx);
6204 ProjectPanelSettings::override_global(
6205 ProjectPanelSettings {
6206 auto_fold_dirs: true,
6207 ..settings
6208 },
6209 cx,
6210 );
6211 });
6212
6213 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6214 cx.run_until_parked();
6215
6216 assert_eq!(
6217 visible_entries_as_strings(&panel, 0..20, cx),
6218 &["v root", " > dir1", " .gitignore",],
6219 "Initial state should show collapsed root structure"
6220 );
6221
6222 toggle_expand_dir(&panel, "root/dir1", cx);
6223 assert_eq!(
6224 visible_entries_as_strings(&panel, 0..20, cx),
6225 &[
6226 "v root",
6227 " v dir1 <== selected",
6228 " > empty1/empty2/empty3",
6229 " > ignored_dir",
6230 " > subdir1",
6231 " .gitignore",
6232 ],
6233 "Should show first level with auto-folded dirs and ignored dir visible"
6234 );
6235
6236 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6237 panel.update_in(cx, |panel, window, cx| {
6238 let project = panel.project.read(cx);
6239 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6240 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6241 panel.update_visible_entries(None, false, false, window, cx);
6242 });
6243 cx.run_until_parked();
6244
6245 assert_eq!(
6246 visible_entries_as_strings(&panel, 0..20, cx),
6247 &[
6248 "v root",
6249 " v dir1 <== selected",
6250 " v empty1",
6251 " v empty2",
6252 " v empty3",
6253 " file.txt",
6254 " > ignored_dir",
6255 " v subdir1",
6256 " > ignored_nested",
6257 " file1.txt",
6258 " file2.txt",
6259 " .gitignore",
6260 ],
6261 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6262 );
6263
6264 // Test 2: When auto-fold is disabled
6265 cx.update(|_, cx| {
6266 let settings = *ProjectPanelSettings::get_global(cx);
6267 ProjectPanelSettings::override_global(
6268 ProjectPanelSettings {
6269 auto_fold_dirs: false,
6270 ..settings
6271 },
6272 cx,
6273 );
6274 });
6275
6276 panel.update_in(cx, |panel, window, cx| {
6277 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6278 });
6279
6280 toggle_expand_dir(&panel, "root/dir1", cx);
6281 assert_eq!(
6282 visible_entries_as_strings(&panel, 0..20, cx),
6283 &[
6284 "v root",
6285 " v dir1 <== selected",
6286 " > empty1",
6287 " > ignored_dir",
6288 " > subdir1",
6289 " .gitignore",
6290 ],
6291 "With auto-fold disabled: should show all directories separately"
6292 );
6293
6294 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6295 panel.update_in(cx, |panel, window, cx| {
6296 let project = panel.project.read(cx);
6297 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6298 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6299 panel.update_visible_entries(None, false, false, window, cx);
6300 });
6301 cx.run_until_parked();
6302
6303 assert_eq!(
6304 visible_entries_as_strings(&panel, 0..20, cx),
6305 &[
6306 "v root",
6307 " v dir1 <== selected",
6308 " v empty1",
6309 " v empty2",
6310 " v empty3",
6311 " file.txt",
6312 " > ignored_dir",
6313 " v subdir1",
6314 " > ignored_nested",
6315 " file1.txt",
6316 " file2.txt",
6317 " .gitignore",
6318 ],
6319 "After expand_all without auto-fold: should expand all dirs normally, \
6320 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6321 );
6322
6323 // Test 3: When explicitly called on ignored directory
6324 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6325 panel.update_in(cx, |panel, window, cx| {
6326 let project = panel.project.read(cx);
6327 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6328 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6329 panel.update_visible_entries(None, false, false, window, cx);
6330 });
6331 cx.run_until_parked();
6332
6333 assert_eq!(
6334 visible_entries_as_strings(&panel, 0..20, cx),
6335 &[
6336 "v root",
6337 " v dir1 <== selected",
6338 " v empty1",
6339 " v empty2",
6340 " v empty3",
6341 " file.txt",
6342 " v ignored_dir",
6343 " v subdir",
6344 " deep_file.txt",
6345 " v subdir1",
6346 " > ignored_nested",
6347 " file1.txt",
6348 " file2.txt",
6349 " .gitignore",
6350 ],
6351 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
6352 );
6353}
6354
6355#[gpui::test]
6356async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
6357 init_test(cx);
6358
6359 let fs = FakeFs::new(cx.executor());
6360 fs.insert_tree(
6361 path!("/root"),
6362 json!({
6363 "dir1": {
6364 "subdir1": {
6365 "nested1": {
6366 "file1.txt": "",
6367 "file2.txt": ""
6368 },
6369 },
6370 "subdir2": {
6371 "file4.txt": ""
6372 }
6373 },
6374 "dir2": {
6375 "single_file": {
6376 "file5.txt": ""
6377 }
6378 }
6379 }),
6380 )
6381 .await;
6382
6383 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6384 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6385 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6386
6387 // Test 1: Basic collapsing
6388 {
6389 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6390 cx.run_until_parked();
6391
6392 toggle_expand_dir(&panel, "root/dir1", cx);
6393 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6394 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6395 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6396
6397 assert_eq!(
6398 visible_entries_as_strings(&panel, 0..20, cx),
6399 &[
6400 "v root",
6401 " v dir1",
6402 " v subdir1",
6403 " v nested1",
6404 " file1.txt",
6405 " file2.txt",
6406 " v subdir2 <== selected",
6407 " file4.txt",
6408 " > dir2",
6409 ],
6410 "Initial state with everything expanded"
6411 );
6412
6413 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6414 panel.update_in(cx, |panel, window, cx| {
6415 let project = panel.project.read(cx);
6416 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6417 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6418 panel.update_visible_entries(None, false, false, window, cx);
6419 });
6420 cx.run_until_parked();
6421
6422 assert_eq!(
6423 visible_entries_as_strings(&panel, 0..20, cx),
6424 &["v root", " > dir1", " > dir2",],
6425 "All subdirs under dir1 should be collapsed"
6426 );
6427 }
6428
6429 // Test 2: With auto-fold enabled
6430 {
6431 cx.update(|_, cx| {
6432 let settings = *ProjectPanelSettings::get_global(cx);
6433 ProjectPanelSettings::override_global(
6434 ProjectPanelSettings {
6435 auto_fold_dirs: true,
6436 ..settings
6437 },
6438 cx,
6439 );
6440 });
6441
6442 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6443 cx.run_until_parked();
6444
6445 toggle_expand_dir(&panel, "root/dir1", cx);
6446 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6447 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6448
6449 assert_eq!(
6450 visible_entries_as_strings(&panel, 0..20, cx),
6451 &[
6452 "v root",
6453 " v dir1",
6454 " v subdir1/nested1 <== selected",
6455 " file1.txt",
6456 " file2.txt",
6457 " > subdir2",
6458 " > dir2/single_file",
6459 ],
6460 "Initial state with some dirs expanded"
6461 );
6462
6463 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6464 panel.update(cx, |panel, cx| {
6465 let project = panel.project.read(cx);
6466 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6467 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6468 });
6469
6470 toggle_expand_dir(&panel, "root/dir1", cx);
6471
6472 assert_eq!(
6473 visible_entries_as_strings(&panel, 0..20, cx),
6474 &[
6475 "v root",
6476 " v dir1 <== selected",
6477 " > subdir1/nested1",
6478 " > subdir2",
6479 " > dir2/single_file",
6480 ],
6481 "Subdirs should be collapsed and folded with auto-fold enabled"
6482 );
6483 }
6484
6485 // Test 3: With auto-fold disabled
6486 {
6487 cx.update(|_, cx| {
6488 let settings = *ProjectPanelSettings::get_global(cx);
6489 ProjectPanelSettings::override_global(
6490 ProjectPanelSettings {
6491 auto_fold_dirs: false,
6492 ..settings
6493 },
6494 cx,
6495 );
6496 });
6497
6498 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6499 cx.run_until_parked();
6500
6501 toggle_expand_dir(&panel, "root/dir1", cx);
6502 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6503 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6504
6505 assert_eq!(
6506 visible_entries_as_strings(&panel, 0..20, cx),
6507 &[
6508 "v root",
6509 " v dir1",
6510 " v subdir1",
6511 " v nested1 <== selected",
6512 " file1.txt",
6513 " file2.txt",
6514 " > subdir2",
6515 " > dir2",
6516 ],
6517 "Initial state with some dirs expanded and auto-fold disabled"
6518 );
6519
6520 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6521 panel.update(cx, |panel, cx| {
6522 let project = panel.project.read(cx);
6523 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6524 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6525 });
6526
6527 toggle_expand_dir(&panel, "root/dir1", cx);
6528
6529 assert_eq!(
6530 visible_entries_as_strings(&panel, 0..20, cx),
6531 &[
6532 "v root",
6533 " v dir1 <== selected",
6534 " > subdir1",
6535 " > subdir2",
6536 " > dir2",
6537 ],
6538 "Subdirs should be collapsed but not folded with auto-fold disabled"
6539 );
6540 }
6541}
6542
6543#[gpui::test]
6544async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
6545 init_test(cx);
6546
6547 let fs = FakeFs::new(cx.executor());
6548 fs.insert_tree(
6549 path!("/root"),
6550 json!({
6551 "dir1": {
6552 "file1.txt": "",
6553 },
6554 }),
6555 )
6556 .await;
6557
6558 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6559 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6560 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6561
6562 let panel = workspace
6563 .update(cx, |workspace, window, cx| {
6564 let panel = ProjectPanel::new(workspace, window, cx);
6565 workspace.add_panel(panel.clone(), window, cx);
6566 panel
6567 })
6568 .unwrap();
6569 cx.run_until_parked();
6570
6571 #[rustfmt::skip]
6572 assert_eq!(
6573 visible_entries_as_strings(&panel, 0..20, cx),
6574 &[
6575 "v root",
6576 " > dir1",
6577 ],
6578 "Initial state with nothing selected"
6579 );
6580
6581 panel.update_in(cx, |panel, window, cx| {
6582 panel.new_file(&NewFile, window, cx);
6583 });
6584 cx.run_until_parked();
6585 panel.update_in(cx, |panel, window, cx| {
6586 assert!(panel.filename_editor.read(cx).is_focused(window));
6587 });
6588 panel
6589 .update_in(cx, |panel, window, cx| {
6590 panel.filename_editor.update(cx, |editor, cx| {
6591 editor.set_text("hello_from_no_selections", window, cx)
6592 });
6593 panel.confirm_edit(true, window, cx).unwrap()
6594 })
6595 .await
6596 .unwrap();
6597 cx.run_until_parked();
6598 #[rustfmt::skip]
6599 assert_eq!(
6600 visible_entries_as_strings(&panel, 0..20, cx),
6601 &[
6602 "v root",
6603 " > dir1",
6604 " hello_from_no_selections <== selected <== marked",
6605 ],
6606 "A new file is created under the root directory"
6607 );
6608}
6609
6610#[gpui::test]
6611async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
6612 init_test(cx);
6613
6614 let fs = FakeFs::new(cx.executor());
6615 fs.insert_tree(
6616 path!("/root"),
6617 json!({
6618 "existing_dir": {
6619 "existing_file.txt": "",
6620 },
6621 "existing_file.txt": "",
6622 }),
6623 )
6624 .await;
6625
6626 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6627 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6628 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6629
6630 cx.update(|_, cx| {
6631 let settings = *ProjectPanelSettings::get_global(cx);
6632 ProjectPanelSettings::override_global(
6633 ProjectPanelSettings {
6634 hide_root: true,
6635 ..settings
6636 },
6637 cx,
6638 );
6639 });
6640
6641 let panel = workspace
6642 .update(cx, |workspace, window, cx| {
6643 let panel = ProjectPanel::new(workspace, window, cx);
6644 workspace.add_panel(panel.clone(), window, cx);
6645 panel
6646 })
6647 .unwrap();
6648 cx.run_until_parked();
6649
6650 #[rustfmt::skip]
6651 assert_eq!(
6652 visible_entries_as_strings(&panel, 0..20, cx),
6653 &[
6654 "> existing_dir",
6655 " existing_file.txt",
6656 ],
6657 "Initial state with hide_root=true, root should be hidden and nothing selected"
6658 );
6659
6660 panel.update(cx, |panel, _| {
6661 assert!(
6662 panel.state.selection.is_none(),
6663 "Should have no selection initially"
6664 );
6665 });
6666
6667 // Test 1: Create new file when no entry is selected
6668 panel.update_in(cx, |panel, window, cx| {
6669 panel.new_file(&NewFile, window, cx);
6670 });
6671 cx.run_until_parked();
6672 panel.update_in(cx, |panel, window, cx| {
6673 assert!(panel.filename_editor.read(cx).is_focused(window));
6674 });
6675 cx.run_until_parked();
6676 #[rustfmt::skip]
6677 assert_eq!(
6678 visible_entries_as_strings(&panel, 0..20, cx),
6679 &[
6680 "> existing_dir",
6681 " [EDITOR: ''] <== selected",
6682 " existing_file.txt",
6683 ],
6684 "Editor should appear at root level when hide_root=true and no selection"
6685 );
6686
6687 let confirm = panel.update_in(cx, |panel, window, cx| {
6688 panel.filename_editor.update(cx, |editor, cx| {
6689 editor.set_text("new_file_at_root.txt", window, cx)
6690 });
6691 panel.confirm_edit(true, window, cx).unwrap()
6692 });
6693 confirm.await.unwrap();
6694 cx.run_until_parked();
6695
6696 #[rustfmt::skip]
6697 assert_eq!(
6698 visible_entries_as_strings(&panel, 0..20, cx),
6699 &[
6700 "> existing_dir",
6701 " existing_file.txt",
6702 " new_file_at_root.txt <== selected <== marked",
6703 ],
6704 "New file should be created at root level and visible without root prefix"
6705 );
6706
6707 assert!(
6708 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
6709 "File should be created in the actual root directory"
6710 );
6711
6712 // Test 2: Create new directory when no entry is selected
6713 panel.update(cx, |panel, _| {
6714 panel.state.selection = None;
6715 });
6716
6717 panel.update_in(cx, |panel, window, cx| {
6718 panel.new_directory(&NewDirectory, window, cx);
6719 });
6720 cx.run_until_parked();
6721
6722 panel.update_in(cx, |panel, window, cx| {
6723 assert!(panel.filename_editor.read(cx).is_focused(window));
6724 });
6725
6726 #[rustfmt::skip]
6727 assert_eq!(
6728 visible_entries_as_strings(&panel, 0..20, cx),
6729 &[
6730 "> [EDITOR: ''] <== selected",
6731 "> existing_dir",
6732 " existing_file.txt",
6733 " new_file_at_root.txt",
6734 ],
6735 "Directory editor should appear at root level when hide_root=true and no selection"
6736 );
6737
6738 let confirm = panel.update_in(cx, |panel, window, cx| {
6739 panel.filename_editor.update(cx, |editor, cx| {
6740 editor.set_text("new_dir_at_root", window, cx)
6741 });
6742 panel.confirm_edit(true, window, cx).unwrap()
6743 });
6744 confirm.await.unwrap();
6745 cx.run_until_parked();
6746
6747 #[rustfmt::skip]
6748 assert_eq!(
6749 visible_entries_as_strings(&panel, 0..20, cx),
6750 &[
6751 "> existing_dir",
6752 "v new_dir_at_root <== selected",
6753 " existing_file.txt",
6754 " new_file_at_root.txt",
6755 ],
6756 "New directory should be created at root level and visible without root prefix"
6757 );
6758
6759 assert!(
6760 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
6761 "Directory should be created in the actual root directory"
6762 );
6763}
6764
6765#[cfg(windows)]
6766#[gpui::test]
6767async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
6768 init_test(cx);
6769
6770 let fs = FakeFs::new(cx.executor());
6771 fs.insert_tree(
6772 path!("/root"),
6773 json!({
6774 "dir1": {
6775 "file1.txt": "",
6776 },
6777 }),
6778 )
6779 .await;
6780
6781 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6782 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6783 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6784
6785 let panel = workspace
6786 .update(cx, |workspace, window, cx| {
6787 let panel = ProjectPanel::new(workspace, window, cx);
6788 workspace.add_panel(panel.clone(), window, cx);
6789 panel
6790 })
6791 .unwrap();
6792 cx.run_until_parked();
6793
6794 #[rustfmt::skip]
6795 assert_eq!(
6796 visible_entries_as_strings(&panel, 0..20, cx),
6797 &[
6798 "v root",
6799 " > dir1",
6800 ],
6801 "Initial state with nothing selected"
6802 );
6803
6804 panel.update_in(cx, |panel, window, cx| {
6805 panel.new_file(&NewFile, window, cx);
6806 });
6807 cx.run_until_parked();
6808 panel.update_in(cx, |panel, window, cx| {
6809 assert!(panel.filename_editor.read(cx).is_focused(window));
6810 });
6811 panel
6812 .update_in(cx, |panel, window, cx| {
6813 panel
6814 .filename_editor
6815 .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
6816 panel.confirm_edit(true, window, cx).unwrap()
6817 })
6818 .await
6819 .unwrap();
6820 cx.run_until_parked();
6821 #[rustfmt::skip]
6822 assert_eq!(
6823 visible_entries_as_strings(&panel, 0..20, cx),
6824 &[
6825 "v root",
6826 " > dir1",
6827 " foo <== selected <== marked",
6828 ],
6829 "A new file is created under the root directory without the trailing dot"
6830 );
6831}
6832
6833#[gpui::test]
6834async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
6835 init_test(cx);
6836
6837 let fs = FakeFs::new(cx.executor());
6838 fs.insert_tree(
6839 "/root",
6840 json!({
6841 "dir1": {
6842 "file1.txt": "",
6843 "dir2": {
6844 "file2.txt": ""
6845 }
6846 },
6847 "file3.txt": ""
6848 }),
6849 )
6850 .await;
6851
6852 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6853 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6854 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6855 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6856 cx.run_until_parked();
6857
6858 panel.update(cx, |panel, cx| {
6859 let project = panel.project.read(cx);
6860 let worktree = project.visible_worktrees(cx).next().unwrap();
6861 let worktree = worktree.read(cx);
6862
6863 // Test 1: Target is a directory, should highlight the directory itself
6864 let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
6865 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
6866 assert_eq!(
6867 result,
6868 Some(dir_entry.id),
6869 "Should highlight directory itself"
6870 );
6871
6872 // Test 2: Target is nested file, should highlight immediate parent
6873 let nested_file = worktree
6874 .entry_for_path(rel_path("dir1/dir2/file2.txt"))
6875 .unwrap();
6876 let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
6877 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
6878 assert_eq!(
6879 result,
6880 Some(nested_parent.id),
6881 "Should highlight immediate parent"
6882 );
6883
6884 // Test 3: Target is root level file, should highlight root
6885 let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
6886 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
6887 assert_eq!(
6888 result,
6889 Some(worktree.root_entry().unwrap().id),
6890 "Root level file should return None"
6891 );
6892
6893 // Test 4: Target is root itself, should highlight root
6894 let root_entry = worktree.root_entry().unwrap();
6895 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
6896 assert_eq!(
6897 result,
6898 Some(root_entry.id),
6899 "Root level file should return None"
6900 );
6901 });
6902}
6903
6904#[gpui::test]
6905async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
6906 init_test(cx);
6907
6908 let fs = FakeFs::new(cx.executor());
6909 fs.insert_tree(
6910 "/root",
6911 json!({
6912 "parent_dir": {
6913 "child_file.txt": "",
6914 "sibling_file.txt": "",
6915 "child_dir": {
6916 "nested_file.txt": ""
6917 }
6918 },
6919 "other_dir": {
6920 "other_file.txt": ""
6921 }
6922 }),
6923 )
6924 .await;
6925
6926 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6927 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6928 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6929 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6930 cx.run_until_parked();
6931
6932 panel.update(cx, |panel, cx| {
6933 let project = panel.project.read(cx);
6934 let worktree = project.visible_worktrees(cx).next().unwrap();
6935 let worktree_id = worktree.read(cx).id();
6936 let worktree = worktree.read(cx);
6937
6938 let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
6939 let child_file = worktree
6940 .entry_for_path(rel_path("parent_dir/child_file.txt"))
6941 .unwrap();
6942 let sibling_file = worktree
6943 .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
6944 .unwrap();
6945 let child_dir = worktree
6946 .entry_for_path(rel_path("parent_dir/child_dir"))
6947 .unwrap();
6948 let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
6949 let other_file = worktree
6950 .entry_for_path(rel_path("other_dir/other_file.txt"))
6951 .unwrap();
6952
6953 // Test 1: Single item drag, don't highlight parent directory
6954 let dragged_selection = DraggedSelection {
6955 active_selection: SelectedEntry {
6956 worktree_id,
6957 entry_id: child_file.id,
6958 },
6959 marked_selections: Arc::new([SelectedEntry {
6960 worktree_id,
6961 entry_id: child_file.id,
6962 }]),
6963 };
6964 let result =
6965 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
6966 assert_eq!(result, None, "Should not highlight parent of dragged item");
6967
6968 // Test 2: Single item drag, don't highlight sibling files
6969 let result = panel.highlight_entry_for_selection_drag(
6970 sibling_file,
6971 worktree,
6972 &dragged_selection,
6973 cx,
6974 );
6975 assert_eq!(result, None, "Should not highlight sibling files");
6976
6977 // Test 3: Single item drag, highlight unrelated directory
6978 let result =
6979 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
6980 assert_eq!(
6981 result,
6982 Some(other_dir.id),
6983 "Should highlight unrelated directory"
6984 );
6985
6986 // Test 4: Single item drag, highlight sibling directory
6987 let result =
6988 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
6989 assert_eq!(
6990 result,
6991 Some(child_dir.id),
6992 "Should highlight sibling directory"
6993 );
6994
6995 // Test 5: Multiple items drag, highlight parent directory
6996 let dragged_selection = DraggedSelection {
6997 active_selection: SelectedEntry {
6998 worktree_id,
6999 entry_id: child_file.id,
7000 },
7001 marked_selections: Arc::new([
7002 SelectedEntry {
7003 worktree_id,
7004 entry_id: child_file.id,
7005 },
7006 SelectedEntry {
7007 worktree_id,
7008 entry_id: sibling_file.id,
7009 },
7010 ]),
7011 };
7012 let result =
7013 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7014 assert_eq!(
7015 result,
7016 Some(parent_dir.id),
7017 "Should highlight parent with multiple items"
7018 );
7019
7020 // Test 6: Target is file in different directory, highlight parent
7021 let result =
7022 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
7023 assert_eq!(
7024 result,
7025 Some(other_dir.id),
7026 "Should highlight parent of target file"
7027 );
7028
7029 // Test 7: Target is directory, always highlight
7030 let result =
7031 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7032 assert_eq!(
7033 result,
7034 Some(child_dir.id),
7035 "Should always highlight directories"
7036 );
7037 });
7038}
7039
7040#[gpui::test]
7041async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
7042 init_test(cx);
7043
7044 let fs = FakeFs::new(cx.executor());
7045 fs.insert_tree(
7046 "/root1",
7047 json!({
7048 "src": {
7049 "main.rs": "",
7050 "lib.rs": ""
7051 }
7052 }),
7053 )
7054 .await;
7055 fs.insert_tree(
7056 "/root2",
7057 json!({
7058 "src": {
7059 "main.rs": "",
7060 "test.rs": ""
7061 }
7062 }),
7063 )
7064 .await;
7065
7066 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7067 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7068 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7069 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7070 cx.run_until_parked();
7071
7072 panel.update(cx, |panel, cx| {
7073 let project = panel.project.read(cx);
7074 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7075
7076 let worktree_a = &worktrees[0];
7077 let main_rs_from_a = worktree_a
7078 .read(cx)
7079 .entry_for_path(rel_path("src/main.rs"))
7080 .unwrap();
7081
7082 let worktree_b = &worktrees[1];
7083 let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
7084 let main_rs_from_b = worktree_b
7085 .read(cx)
7086 .entry_for_path(rel_path("src/main.rs"))
7087 .unwrap();
7088
7089 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
7090 let dragged_selection = DraggedSelection {
7091 active_selection: SelectedEntry {
7092 worktree_id: worktree_a.read(cx).id(),
7093 entry_id: main_rs_from_a.id,
7094 },
7095 marked_selections: Arc::new([SelectedEntry {
7096 worktree_id: worktree_a.read(cx).id(),
7097 entry_id: main_rs_from_a.id,
7098 }]),
7099 };
7100
7101 let result = panel.highlight_entry_for_selection_drag(
7102 src_dir_from_b,
7103 worktree_b.read(cx),
7104 &dragged_selection,
7105 cx,
7106 );
7107 assert_eq!(
7108 result,
7109 Some(src_dir_from_b.id),
7110 "Should highlight target directory from different worktree even with same relative path"
7111 );
7112
7113 // Test dragging file from worktree A onto file with same relative path in worktree B
7114 let result = panel.highlight_entry_for_selection_drag(
7115 main_rs_from_b,
7116 worktree_b.read(cx),
7117 &dragged_selection,
7118 cx,
7119 );
7120 assert_eq!(
7121 result,
7122 Some(src_dir_from_b.id),
7123 "Should highlight parent of target file from different worktree"
7124 );
7125 });
7126}
7127
7128#[gpui::test]
7129async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
7130 init_test(cx);
7131
7132 let fs = FakeFs::new(cx.executor());
7133 fs.insert_tree(
7134 "/root1",
7135 json!({
7136 "parent_dir": {
7137 "child_file.txt": "",
7138 "nested_dir": {
7139 "nested_file.txt": ""
7140 }
7141 },
7142 "root_file.txt": ""
7143 }),
7144 )
7145 .await;
7146
7147 fs.insert_tree(
7148 "/root2",
7149 json!({
7150 "other_dir": {
7151 "other_file.txt": ""
7152 }
7153 }),
7154 )
7155 .await;
7156
7157 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7158 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7159 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7160 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7161 cx.run_until_parked();
7162
7163 panel.update(cx, |panel, cx| {
7164 let project = panel.project.read(cx);
7165 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7166 let worktree1 = worktrees[0].read(cx);
7167 let worktree2 = worktrees[1].read(cx);
7168 let worktree1_id = worktree1.id();
7169 let _worktree2_id = worktree2.id();
7170
7171 let root1_entry = worktree1.root_entry().unwrap();
7172 let root2_entry = worktree2.root_entry().unwrap();
7173 let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
7174 let child_file = worktree1
7175 .entry_for_path(rel_path("parent_dir/child_file.txt"))
7176 .unwrap();
7177 let nested_file = worktree1
7178 .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
7179 .unwrap();
7180 let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
7181
7182 // Test 1: Multiple entries - should always highlight background
7183 let multiple_dragged_selection = DraggedSelection {
7184 active_selection: SelectedEntry {
7185 worktree_id: worktree1_id,
7186 entry_id: child_file.id,
7187 },
7188 marked_selections: Arc::new([
7189 SelectedEntry {
7190 worktree_id: worktree1_id,
7191 entry_id: child_file.id,
7192 },
7193 SelectedEntry {
7194 worktree_id: worktree1_id,
7195 entry_id: nested_file.id,
7196 },
7197 ]),
7198 };
7199
7200 let result = panel.should_highlight_background_for_selection_drag(
7201 &multiple_dragged_selection,
7202 root1_entry.id,
7203 cx,
7204 );
7205 assert!(result, "Should highlight background for multiple entries");
7206
7207 // Test 2: Single entry with non-empty parent path - should highlight background
7208 let nested_dragged_selection = DraggedSelection {
7209 active_selection: SelectedEntry {
7210 worktree_id: worktree1_id,
7211 entry_id: nested_file.id,
7212 },
7213 marked_selections: Arc::new([SelectedEntry {
7214 worktree_id: worktree1_id,
7215 entry_id: nested_file.id,
7216 }]),
7217 };
7218
7219 let result = panel.should_highlight_background_for_selection_drag(
7220 &nested_dragged_selection,
7221 root1_entry.id,
7222 cx,
7223 );
7224 assert!(result, "Should highlight background for nested file");
7225
7226 // Test 3: Single entry at root level, same worktree - should NOT highlight background
7227 let root_file_dragged_selection = DraggedSelection {
7228 active_selection: SelectedEntry {
7229 worktree_id: worktree1_id,
7230 entry_id: root_file.id,
7231 },
7232 marked_selections: Arc::new([SelectedEntry {
7233 worktree_id: worktree1_id,
7234 entry_id: root_file.id,
7235 }]),
7236 };
7237
7238 let result = panel.should_highlight_background_for_selection_drag(
7239 &root_file_dragged_selection,
7240 root1_entry.id,
7241 cx,
7242 );
7243 assert!(
7244 !result,
7245 "Should NOT highlight background for root file in same worktree"
7246 );
7247
7248 // Test 4: Single entry at root level, different worktree - should highlight background
7249 let result = panel.should_highlight_background_for_selection_drag(
7250 &root_file_dragged_selection,
7251 root2_entry.id,
7252 cx,
7253 );
7254 assert!(
7255 result,
7256 "Should highlight background for root file from different worktree"
7257 );
7258
7259 // Test 5: Single entry in subdirectory - should highlight background
7260 let child_file_dragged_selection = DraggedSelection {
7261 active_selection: SelectedEntry {
7262 worktree_id: worktree1_id,
7263 entry_id: child_file.id,
7264 },
7265 marked_selections: Arc::new([SelectedEntry {
7266 worktree_id: worktree1_id,
7267 entry_id: child_file.id,
7268 }]),
7269 };
7270
7271 let result = panel.should_highlight_background_for_selection_drag(
7272 &child_file_dragged_selection,
7273 root1_entry.id,
7274 cx,
7275 );
7276 assert!(
7277 result,
7278 "Should highlight background for file with non-empty parent path"
7279 );
7280 });
7281}
7282
7283#[gpui::test]
7284async fn test_hide_root(cx: &mut gpui::TestAppContext) {
7285 init_test(cx);
7286
7287 let fs = FakeFs::new(cx.executor());
7288 fs.insert_tree(
7289 "/root1",
7290 json!({
7291 "dir1": {
7292 "file1.txt": "content",
7293 "file2.txt": "content",
7294 },
7295 "dir2": {
7296 "file3.txt": "content",
7297 },
7298 "file4.txt": "content",
7299 }),
7300 )
7301 .await;
7302
7303 fs.insert_tree(
7304 "/root2",
7305 json!({
7306 "dir3": {
7307 "file5.txt": "content",
7308 },
7309 "file6.txt": "content",
7310 }),
7311 )
7312 .await;
7313
7314 // Test 1: Single worktree with hide_root = false
7315 {
7316 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7317 let workspace =
7318 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7319 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7320
7321 cx.update(|_, cx| {
7322 let settings = *ProjectPanelSettings::get_global(cx);
7323 ProjectPanelSettings::override_global(
7324 ProjectPanelSettings {
7325 hide_root: false,
7326 ..settings
7327 },
7328 cx,
7329 );
7330 });
7331
7332 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7333 cx.run_until_parked();
7334
7335 #[rustfmt::skip]
7336 assert_eq!(
7337 visible_entries_as_strings(&panel, 0..10, cx),
7338 &[
7339 "v root1",
7340 " > dir1",
7341 " > dir2",
7342 " file4.txt",
7343 ],
7344 "With hide_root=false and single worktree, root should be visible"
7345 );
7346 }
7347
7348 // Test 2: Single worktree with hide_root = true
7349 {
7350 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7351 let workspace =
7352 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7353 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7354
7355 // Set hide_root to true
7356 cx.update(|_, cx| {
7357 let settings = *ProjectPanelSettings::get_global(cx);
7358 ProjectPanelSettings::override_global(
7359 ProjectPanelSettings {
7360 hide_root: true,
7361 ..settings
7362 },
7363 cx,
7364 );
7365 });
7366
7367 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7368 cx.run_until_parked();
7369
7370 assert_eq!(
7371 visible_entries_as_strings(&panel, 0..10, cx),
7372 &["> dir1", "> dir2", " file4.txt",],
7373 "With hide_root=true and single worktree, root should be hidden"
7374 );
7375
7376 // Test expanding directories still works without root
7377 toggle_expand_dir(&panel, "root1/dir1", cx);
7378 assert_eq!(
7379 visible_entries_as_strings(&panel, 0..10, cx),
7380 &[
7381 "v dir1 <== selected",
7382 " file1.txt",
7383 " file2.txt",
7384 "> dir2",
7385 " file4.txt",
7386 ],
7387 "Should be able to expand directories even when root is hidden"
7388 );
7389 }
7390
7391 // Test 3: Multiple worktrees with hide_root = true
7392 {
7393 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7394 let workspace =
7395 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7396 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7397
7398 // Set hide_root to true
7399 cx.update(|_, cx| {
7400 let settings = *ProjectPanelSettings::get_global(cx);
7401 ProjectPanelSettings::override_global(
7402 ProjectPanelSettings {
7403 hide_root: true,
7404 ..settings
7405 },
7406 cx,
7407 );
7408 });
7409
7410 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7411 cx.run_until_parked();
7412
7413 assert_eq!(
7414 visible_entries_as_strings(&panel, 0..10, cx),
7415 &[
7416 "v root1",
7417 " > dir1",
7418 " > dir2",
7419 " file4.txt",
7420 "v root2",
7421 " > dir3",
7422 " file6.txt",
7423 ],
7424 "With hide_root=true and multiple worktrees, roots should still be visible"
7425 );
7426 }
7427
7428 // Test 4: Multiple worktrees with hide_root = false
7429 {
7430 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7431 let workspace =
7432 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7433 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7434
7435 cx.update(|_, cx| {
7436 let settings = *ProjectPanelSettings::get_global(cx);
7437 ProjectPanelSettings::override_global(
7438 ProjectPanelSettings {
7439 hide_root: false,
7440 ..settings
7441 },
7442 cx,
7443 );
7444 });
7445
7446 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7447 cx.run_until_parked();
7448
7449 assert_eq!(
7450 visible_entries_as_strings(&panel, 0..10, cx),
7451 &[
7452 "v root1",
7453 " > dir1",
7454 " > dir2",
7455 " file4.txt",
7456 "v root2",
7457 " > dir3",
7458 " file6.txt",
7459 ],
7460 "With hide_root=false and multiple worktrees, roots should be visible"
7461 );
7462 }
7463}
7464
7465#[gpui::test]
7466async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
7467 init_test_with_editor(cx);
7468
7469 let fs = FakeFs::new(cx.executor());
7470 fs.insert_tree(
7471 "/root",
7472 json!({
7473 "file1.txt": "content of file1",
7474 "file2.txt": "content of file2",
7475 "dir1": {
7476 "file3.txt": "content of file3"
7477 }
7478 }),
7479 )
7480 .await;
7481
7482 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7483 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7484 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7485 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7486 cx.run_until_parked();
7487
7488 let file1_path = "root/file1.txt";
7489 let file2_path = "root/file2.txt";
7490 select_path_with_mark(&panel, file1_path, cx);
7491 select_path_with_mark(&panel, file2_path, cx);
7492
7493 panel.update_in(cx, |panel, window, cx| {
7494 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
7495 });
7496 cx.executor().run_until_parked();
7497
7498 workspace
7499 .update(cx, |workspace, _, cx| {
7500 let active_items = workspace
7501 .panes()
7502 .iter()
7503 .filter_map(|pane| pane.read(cx).active_item())
7504 .collect::<Vec<_>>();
7505 assert_eq!(active_items.len(), 1);
7506 let diff_view = active_items
7507 .into_iter()
7508 .next()
7509 .unwrap()
7510 .downcast::<FileDiffView>()
7511 .expect("Open item should be an FileDiffView");
7512 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
7513 assert_eq!(
7514 diff_view.tab_tooltip_text(cx).unwrap(),
7515 format!(
7516 "{} ↔ {}",
7517 rel_path(file1_path).display(PathStyle::local()),
7518 rel_path(file2_path).display(PathStyle::local())
7519 )
7520 );
7521 })
7522 .unwrap();
7523
7524 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
7525 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
7526 let worktree_id = panel.update(cx, |panel, cx| {
7527 panel
7528 .project
7529 .read(cx)
7530 .worktrees(cx)
7531 .next()
7532 .unwrap()
7533 .read(cx)
7534 .id()
7535 });
7536
7537 let expected_entries = [
7538 SelectedEntry {
7539 worktree_id,
7540 entry_id: file1_entry_id,
7541 },
7542 SelectedEntry {
7543 worktree_id,
7544 entry_id: file2_entry_id,
7545 },
7546 ];
7547 panel.update(cx, |panel, _cx| {
7548 assert_eq!(
7549 &panel.marked_entries, &expected_entries,
7550 "Should keep marked entries after comparison"
7551 );
7552 });
7553
7554 panel.update(cx, |panel, cx| {
7555 panel.project.update(cx, |_, cx| {
7556 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
7557 })
7558 });
7559
7560 panel.update(cx, |panel, _cx| {
7561 assert_eq!(
7562 &panel.marked_entries, &expected_entries,
7563 "Marked entries should persist after focusing back on the project panel"
7564 );
7565 });
7566}
7567
7568#[gpui::test]
7569async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
7570 init_test_with_editor(cx);
7571
7572 let fs = FakeFs::new(cx.executor());
7573 fs.insert_tree(
7574 "/root",
7575 json!({
7576 "file1.txt": "content of file1",
7577 "file2.txt": "content of file2",
7578 "dir1": {},
7579 "dir2": {
7580 "file3.txt": "content of file3"
7581 }
7582 }),
7583 )
7584 .await;
7585
7586 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7587 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7588 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7589 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7590 cx.run_until_parked();
7591
7592 // Test 1: When only one file is selected, there should be no compare option
7593 select_path(&panel, "root/file1.txt", cx);
7594
7595 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
7596 assert_eq!(
7597 selected_files, None,
7598 "Should not have compare option when only one file is selected"
7599 );
7600
7601 // Test 2: When multiple files are selected, there should be a compare option
7602 select_path_with_mark(&panel, "root/file1.txt", cx);
7603 select_path_with_mark(&panel, "root/file2.txt", cx);
7604
7605 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
7606 assert!(
7607 selected_files.is_some(),
7608 "Should have files selected for comparison"
7609 );
7610 if let Some((file1, file2)) = selected_files {
7611 assert!(
7612 file1.to_string_lossy().ends_with("file1.txt")
7613 && file2.to_string_lossy().ends_with("file2.txt"),
7614 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
7615 );
7616 }
7617
7618 // Test 3: Selecting a directory shouldn't count as a comparable file
7619 select_path_with_mark(&panel, "root/dir1", cx);
7620
7621 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
7622 assert!(
7623 selected_files.is_some(),
7624 "Directory selection should not affect comparable files"
7625 );
7626 if let Some((file1, file2)) = selected_files {
7627 assert!(
7628 file1.to_string_lossy().ends_with("file1.txt")
7629 && file2.to_string_lossy().ends_with("file2.txt"),
7630 "Selecting a directory should not affect the number of comparable files"
7631 );
7632 }
7633
7634 // Test 4: Selecting one more file
7635 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
7636
7637 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
7638 assert!(
7639 selected_files.is_some(),
7640 "Directory selection should not affect comparable files"
7641 );
7642 if let Some((file1, file2)) = selected_files {
7643 assert!(
7644 file1.to_string_lossy().ends_with("file2.txt")
7645 && file2.to_string_lossy().ends_with("file3.txt"),
7646 "Selecting a directory should not affect the number of comparable files"
7647 );
7648 }
7649}
7650
7651#[gpui::test]
7652async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
7653 init_test(cx);
7654
7655 let fs = FakeFs::new(cx.executor());
7656 fs.insert_tree(
7657 "/root",
7658 json!({
7659 ".hidden-file.txt": "hidden file content",
7660 "visible-file.txt": "visible file content",
7661 ".hidden-parent-dir": {
7662 "nested-dir": {
7663 "file.txt": "file content",
7664 }
7665 },
7666 "visible-dir": {
7667 "file-in-visible.txt": "file content",
7668 "nested": {
7669 ".hidden-nested-dir": {
7670 ".double-hidden-dir": {
7671 "deep-file-1.txt": "deep content 1",
7672 "deep-file-2.txt": "deep content 2"
7673 },
7674 "hidden-nested-file-1.txt": "hidden nested 1",
7675 "hidden-nested-file-2.txt": "hidden nested 2"
7676 },
7677 "visible-nested-file.txt": "visible nested content"
7678 }
7679 }
7680 }),
7681 )
7682 .await;
7683
7684 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7685 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7686 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7687
7688 cx.update(|_, cx| {
7689 let settings = *ProjectPanelSettings::get_global(cx);
7690 ProjectPanelSettings::override_global(
7691 ProjectPanelSettings {
7692 hide_hidden: false,
7693 ..settings
7694 },
7695 cx,
7696 );
7697 });
7698
7699 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7700 cx.run_until_parked();
7701
7702 toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
7703 toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
7704 toggle_expand_dir(&panel, "root/visible-dir", cx);
7705 toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
7706 toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
7707 toggle_expand_dir(
7708 &panel,
7709 "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
7710 cx,
7711 );
7712
7713 let expanded = [
7714 "v root",
7715 " v .hidden-parent-dir",
7716 " v nested-dir",
7717 " file.txt",
7718 " v visible-dir",
7719 " v nested",
7720 " v .hidden-nested-dir",
7721 " v .double-hidden-dir <== selected",
7722 " deep-file-1.txt",
7723 " deep-file-2.txt",
7724 " hidden-nested-file-1.txt",
7725 " hidden-nested-file-2.txt",
7726 " visible-nested-file.txt",
7727 " file-in-visible.txt",
7728 " .hidden-file.txt",
7729 " visible-file.txt",
7730 ];
7731
7732 assert_eq!(
7733 visible_entries_as_strings(&panel, 0..30, cx),
7734 &expanded,
7735 "With hide_hidden=false, contents of hidden nested directory should be visible"
7736 );
7737
7738 cx.update(|_, cx| {
7739 let settings = *ProjectPanelSettings::get_global(cx);
7740 ProjectPanelSettings::override_global(
7741 ProjectPanelSettings {
7742 hide_hidden: true,
7743 ..settings
7744 },
7745 cx,
7746 );
7747 });
7748
7749 panel.update_in(cx, |panel, window, cx| {
7750 panel.update_visible_entries(None, false, false, window, cx);
7751 });
7752 cx.run_until_parked();
7753
7754 assert_eq!(
7755 visible_entries_as_strings(&panel, 0..30, cx),
7756 &[
7757 "v root",
7758 " v visible-dir",
7759 " v nested",
7760 " visible-nested-file.txt",
7761 " file-in-visible.txt",
7762 " visible-file.txt",
7763 ],
7764 "With hide_hidden=false, contents of hidden nested directory should be visible"
7765 );
7766
7767 panel.update_in(cx, |panel, window, cx| {
7768 let settings = *ProjectPanelSettings::get_global(cx);
7769 ProjectPanelSettings::override_global(
7770 ProjectPanelSettings {
7771 hide_hidden: false,
7772 ..settings
7773 },
7774 cx,
7775 );
7776 panel.update_visible_entries(None, false, false, window, cx);
7777 });
7778 cx.run_until_parked();
7779
7780 assert_eq!(
7781 visible_entries_as_strings(&panel, 0..30, cx),
7782 &expanded,
7783 "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
7784 );
7785}
7786
7787fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
7788 let path = rel_path(path);
7789 panel.update_in(cx, |panel, window, cx| {
7790 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7791 let worktree = worktree.read(cx);
7792 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7793 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7794 panel.update_visible_entries(
7795 Some((worktree.id(), entry_id)),
7796 false,
7797 false,
7798 window,
7799 cx,
7800 );
7801 return;
7802 }
7803 }
7804 panic!("no worktree for path {:?}", path);
7805 });
7806 cx.run_until_parked();
7807}
7808
7809fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
7810 let path = rel_path(path);
7811 panel.update(cx, |panel, cx| {
7812 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7813 let worktree = worktree.read(cx);
7814 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7815 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7816 let entry = crate::SelectedEntry {
7817 worktree_id: worktree.id(),
7818 entry_id,
7819 };
7820 if !panel.marked_entries.contains(&entry) {
7821 panel.marked_entries.push(entry);
7822 }
7823 panel.state.selection = Some(entry);
7824 return;
7825 }
7826 }
7827 panic!("no worktree for path {:?}", path);
7828 });
7829}
7830
7831/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
7832/// `active_ancestor_path` is the path to the ancestor that should be "active" (e.g., "root/a/b")
7833fn select_folded_path_with_mark(
7834 panel: &Entity<ProjectPanel>,
7835 leaf_path: &str,
7836 active_ancestor_path: &str,
7837 cx: &mut VisualTestContext,
7838) {
7839 select_path_with_mark(panel, leaf_path, cx);
7840 let active_ancestor_path = rel_path(active_ancestor_path);
7841 panel.update(cx, |panel, cx| {
7842 let leaf_entry_id = panel.state.selection.unwrap().entry_id;
7843 if let Some(folded_ancestors) = panel.state.ancestors.get_mut(&leaf_entry_id) {
7844 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7845 let worktree = worktree.read(cx);
7846 if let Ok(active_relative_path) =
7847 active_ancestor_path.strip_prefix(worktree.root_name())
7848 {
7849 let active_entry_id = worktree.entry_for_path(active_relative_path).unwrap().id;
7850 if let Some(index) = folded_ancestors
7851 .ancestors
7852 .iter()
7853 .position(|&id| id == active_entry_id)
7854 {
7855 folded_ancestors.current_ancestor_depth =
7856 folded_ancestors.ancestors.len() - 1 - index;
7857 }
7858 return;
7859 }
7860 }
7861 }
7862 });
7863}
7864
7865fn drag_selection_to(
7866 panel: &Entity<ProjectPanel>,
7867 target_path: &str,
7868 is_file: bool,
7869 cx: &mut VisualTestContext,
7870) {
7871 let target_entry = find_project_entry(panel, target_path, cx)
7872 .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
7873
7874 panel.update_in(cx, |panel, window, cx| {
7875 let selection = panel
7876 .state
7877 .selection
7878 .expect("a selection is required before dragging");
7879 let drag = DraggedSelection {
7880 active_selection: SelectedEntry {
7881 worktree_id: selection.worktree_id,
7882 entry_id: panel.resolve_entry(selection.entry_id),
7883 },
7884 marked_selections: Arc::from(panel.marked_entries.clone()),
7885 };
7886 panel.drag_onto(&drag, target_entry, is_file, window, cx);
7887 });
7888 cx.executor().run_until_parked();
7889}
7890
7891fn find_project_entry(
7892 panel: &Entity<ProjectPanel>,
7893 path: &str,
7894 cx: &mut VisualTestContext,
7895) -> Option<ProjectEntryId> {
7896 let path = rel_path(path);
7897 panel.update(cx, |panel, cx| {
7898 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7899 let worktree = worktree.read(cx);
7900 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7901 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
7902 }
7903 }
7904 panic!("no worktree for path {path:?}");
7905 })
7906}
7907
7908fn visible_entries_as_strings(
7909 panel: &Entity<ProjectPanel>,
7910 range: Range<usize>,
7911 cx: &mut VisualTestContext,
7912) -> Vec<String> {
7913 let mut result = Vec::new();
7914 let mut project_entries = HashSet::default();
7915 let mut has_editor = false;
7916
7917 panel.update_in(cx, |panel, window, cx| {
7918 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
7919 if details.is_editing {
7920 assert!(!has_editor, "duplicate editor entry");
7921 has_editor = true;
7922 } else {
7923 assert!(
7924 project_entries.insert(project_entry),
7925 "duplicate project entry {:?} {:?}",
7926 project_entry,
7927 details
7928 );
7929 }
7930
7931 let indent = " ".repeat(details.depth);
7932 let icon = if details.kind.is_dir() {
7933 if details.is_expanded { "v " } else { "> " }
7934 } else {
7935 " "
7936 };
7937 #[cfg(windows)]
7938 let filename = details.filename.replace("\\", "/");
7939 #[cfg(not(windows))]
7940 let filename = details.filename;
7941 let name = if details.is_editing {
7942 format!("[EDITOR: '{}']", filename)
7943 } else if details.is_processing {
7944 format!("[PROCESSING: '{}']", filename)
7945 } else {
7946 filename
7947 };
7948 let selected = if details.is_selected {
7949 " <== selected"
7950 } else {
7951 ""
7952 };
7953 let marked = if details.is_marked {
7954 " <== marked"
7955 } else {
7956 ""
7957 };
7958
7959 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
7960 });
7961 });
7962
7963 result
7964}
7965
7966/// Test that missing sort_mode field defaults to DirectoriesFirst
7967#[gpui::test]
7968async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
7969 init_test(cx);
7970
7971 // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
7972 let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
7973 assert_eq!(
7974 default_settings.sort_mode,
7975 settings::ProjectPanelSortMode::DirectoriesFirst,
7976 "sort_mode should default to DirectoriesFirst"
7977 );
7978}
7979
7980/// Test sort modes: DirectoriesFirst (default) vs Mixed
7981#[gpui::test]
7982async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
7983 init_test(cx);
7984
7985 let fs = FakeFs::new(cx.executor());
7986 fs.insert_tree(
7987 "/root",
7988 json!({
7989 "zebra.txt": "",
7990 "Apple": {},
7991 "banana.rs": "",
7992 "Carrot": {},
7993 "aardvark.txt": "",
7994 }),
7995 )
7996 .await;
7997
7998 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7999 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8000 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8001 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8002 cx.run_until_parked();
8003
8004 // Default sort mode should be DirectoriesFirst
8005 assert_eq!(
8006 visible_entries_as_strings(&panel, 0..50, cx),
8007 &[
8008 "v root",
8009 " > Apple",
8010 " > Carrot",
8011 " aardvark.txt",
8012 " banana.rs",
8013 " zebra.txt",
8014 ]
8015 );
8016}
8017
8018#[gpui::test]
8019async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
8020 init_test(cx);
8021
8022 let fs = FakeFs::new(cx.executor());
8023 fs.insert_tree(
8024 "/root",
8025 json!({
8026 "Zebra.txt": "",
8027 "apple": {},
8028 "Banana.rs": "",
8029 "carrot": {},
8030 "Aardvark.txt": "",
8031 }),
8032 )
8033 .await;
8034
8035 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8036 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8037 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8038
8039 // Switch to Mixed mode
8040 cx.update(|_, cx| {
8041 cx.update_global::<SettingsStore, _>(|store, cx| {
8042 store.update_user_settings(cx, |settings| {
8043 settings.project_panel.get_or_insert_default().sort_mode =
8044 Some(settings::ProjectPanelSortMode::Mixed);
8045 });
8046 });
8047 });
8048
8049 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8050 cx.run_until_parked();
8051
8052 // Mixed mode: case-insensitive sorting
8053 // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
8054 assert_eq!(
8055 visible_entries_as_strings(&panel, 0..50, cx),
8056 &[
8057 "v root",
8058 " Aardvark.txt",
8059 " > apple",
8060 " Banana.rs",
8061 " > carrot",
8062 " Zebra.txt",
8063 ]
8064 );
8065}
8066
8067#[gpui::test]
8068async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
8069 init_test(cx);
8070
8071 let fs = FakeFs::new(cx.executor());
8072 fs.insert_tree(
8073 "/root",
8074 json!({
8075 "Zebra.txt": "",
8076 "apple": {},
8077 "Banana.rs": "",
8078 "carrot": {},
8079 "Aardvark.txt": "",
8080 }),
8081 )
8082 .await;
8083
8084 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8085 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8086 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8087
8088 // Switch to FilesFirst mode
8089 cx.update(|_, cx| {
8090 cx.update_global::<SettingsStore, _>(|store, cx| {
8091 store.update_user_settings(cx, |settings| {
8092 settings.project_panel.get_or_insert_default().sort_mode =
8093 Some(settings::ProjectPanelSortMode::FilesFirst);
8094 });
8095 });
8096 });
8097
8098 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8099 cx.run_until_parked();
8100
8101 // FilesFirst mode: files first, then directories (both case-insensitive)
8102 assert_eq!(
8103 visible_entries_as_strings(&panel, 0..50, cx),
8104 &[
8105 "v root",
8106 " Aardvark.txt",
8107 " Banana.rs",
8108 " Zebra.txt",
8109 " > apple",
8110 " > carrot",
8111 ]
8112 );
8113}
8114
8115#[gpui::test]
8116async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
8117 init_test(cx);
8118
8119 let fs = FakeFs::new(cx.executor());
8120 fs.insert_tree(
8121 "/root",
8122 json!({
8123 "file2.txt": "",
8124 "dir1": {},
8125 "file1.txt": "",
8126 }),
8127 )
8128 .await;
8129
8130 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8131 let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8132 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8133 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8134 cx.run_until_parked();
8135
8136 // Initially DirectoriesFirst
8137 assert_eq!(
8138 visible_entries_as_strings(&panel, 0..50, cx),
8139 &["v root", " > dir1", " file1.txt", " file2.txt",]
8140 );
8141
8142 // Toggle to Mixed
8143 cx.update(|_, cx| {
8144 cx.update_global::<SettingsStore, _>(|store, cx| {
8145 store.update_user_settings(cx, |settings| {
8146 settings.project_panel.get_or_insert_default().sort_mode =
8147 Some(settings::ProjectPanelSortMode::Mixed);
8148 });
8149 });
8150 });
8151 cx.run_until_parked();
8152
8153 assert_eq!(
8154 visible_entries_as_strings(&panel, 0..50, cx),
8155 &["v root", " > dir1", " file1.txt", " file2.txt",]
8156 );
8157
8158 // Toggle back to DirectoriesFirst
8159 cx.update(|_, cx| {
8160 cx.update_global::<SettingsStore, _>(|store, cx| {
8161 store.update_user_settings(cx, |settings| {
8162 settings.project_panel.get_or_insert_default().sort_mode =
8163 Some(settings::ProjectPanelSortMode::DirectoriesFirst);
8164 });
8165 });
8166 });
8167 cx.run_until_parked();
8168
8169 assert_eq!(
8170 visible_entries_as_strings(&panel, 0..50, cx),
8171 &["v root", " > dir1", " file1.txt", " file2.txt",]
8172 );
8173}
8174
8175fn init_test(cx: &mut TestAppContext) {
8176 cx.update(|cx| {
8177 let settings_store = SettingsStore::test(cx);
8178 cx.set_global(settings_store);
8179 theme::init(theme::LoadThemes::JustBase, cx);
8180 crate::init(cx);
8181
8182 cx.update_global::<SettingsStore, _>(|store, cx| {
8183 store.update_user_settings(cx, |settings| {
8184 settings
8185 .project_panel
8186 .get_or_insert_default()
8187 .auto_fold_dirs = Some(false);
8188 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
8189 });
8190 });
8191 });
8192}
8193
8194fn init_test_with_editor(cx: &mut TestAppContext) {
8195 cx.update(|cx| {
8196 let app_state = AppState::test(cx);
8197 theme::init(theme::LoadThemes::JustBase, cx);
8198 editor::init(cx);
8199 crate::init(cx);
8200 workspace::init(app_state, cx);
8201
8202 cx.update_global::<SettingsStore, _>(|store, cx| {
8203 store.update_user_settings(cx, |settings| {
8204 settings
8205 .project_panel
8206 .get_or_insert_default()
8207 .auto_fold_dirs = Some(false);
8208 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
8209 });
8210 });
8211 });
8212}
8213
8214fn set_auto_open_settings(
8215 cx: &mut TestAppContext,
8216 auto_open_settings: ProjectPanelAutoOpenSettings,
8217) {
8218 cx.update(|cx| {
8219 cx.update_global::<SettingsStore, _>(|store, cx| {
8220 store.update_user_settings(cx, |settings| {
8221 settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
8222 });
8223 })
8224 });
8225}
8226
8227fn ensure_single_file_is_opened(
8228 window: &WindowHandle<Workspace>,
8229 expected_path: &str,
8230 cx: &mut TestAppContext,
8231) {
8232 window
8233 .update(cx, |workspace, _, cx| {
8234 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
8235 assert_eq!(worktrees.len(), 1);
8236 let worktree_id = worktrees[0].read(cx).id();
8237
8238 let open_project_paths = workspace
8239 .panes()
8240 .iter()
8241 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8242 .collect::<Vec<_>>();
8243 assert_eq!(
8244 open_project_paths,
8245 vec![ProjectPath {
8246 worktree_id,
8247 path: Arc::from(rel_path(expected_path))
8248 }],
8249 "Should have opened file, selected in project panel"
8250 );
8251 })
8252 .unwrap();
8253}
8254
8255fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
8256 assert!(
8257 !cx.has_pending_prompt(),
8258 "Should have no prompts before the deletion"
8259 );
8260 panel.update_in(cx, |panel, window, cx| {
8261 panel.delete(&Delete { skip_prompt: false }, window, cx)
8262 });
8263 assert!(
8264 cx.has_pending_prompt(),
8265 "Should have a prompt after the deletion"
8266 );
8267 cx.simulate_prompt_answer("Delete");
8268 assert!(
8269 !cx.has_pending_prompt(),
8270 "Should have no prompts after prompt was replied to"
8271 );
8272 cx.executor().run_until_parked();
8273}
8274
8275fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
8276 assert!(
8277 !cx.has_pending_prompt(),
8278 "Should have no prompts before the deletion"
8279 );
8280 panel.update_in(cx, |panel, window, cx| {
8281 panel.delete(&Delete { skip_prompt: true }, window, cx)
8282 });
8283 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8284 cx.executor().run_until_parked();
8285}
8286
8287fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
8288 assert!(
8289 !cx.has_pending_prompt(),
8290 "Should have no prompts after deletion operation closes the file"
8291 );
8292 workspace
8293 .read_with(cx, |workspace, cx| {
8294 let open_project_paths = workspace
8295 .panes()
8296 .iter()
8297 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8298 .collect::<Vec<_>>();
8299 assert!(
8300 open_project_paths.is_empty(),
8301 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8302 );
8303 })
8304 .unwrap();
8305}
8306
8307struct TestProjectItemView {
8308 focus_handle: FocusHandle,
8309 path: ProjectPath,
8310}
8311
8312struct TestProjectItem {
8313 path: ProjectPath,
8314}
8315
8316impl project::ProjectItem for TestProjectItem {
8317 fn try_open(
8318 _project: &Entity<Project>,
8319 path: &ProjectPath,
8320 cx: &mut App,
8321 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
8322 let path = path.clone();
8323 Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
8324 }
8325
8326 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
8327 None
8328 }
8329
8330 fn project_path(&self, _: &App) -> Option<ProjectPath> {
8331 Some(self.path.clone())
8332 }
8333
8334 fn is_dirty(&self) -> bool {
8335 false
8336 }
8337}
8338
8339impl ProjectItem for TestProjectItemView {
8340 type Item = TestProjectItem;
8341
8342 fn for_project_item(
8343 _: Entity<Project>,
8344 _: Option<&Pane>,
8345 project_item: Entity<Self::Item>,
8346 _: &mut Window,
8347 cx: &mut Context<Self>,
8348 ) -> Self
8349 where
8350 Self: Sized,
8351 {
8352 Self {
8353 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8354 focus_handle: cx.focus_handle(),
8355 }
8356 }
8357}
8358
8359impl Item for TestProjectItemView {
8360 type Event = ();
8361
8362 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
8363 "Test".into()
8364 }
8365}
8366
8367impl EventEmitter<()> for TestProjectItemView {}
8368
8369impl Focusable for TestProjectItemView {
8370 fn focus_handle(&self, _: &App) -> FocusHandle {
8371 self.focus_handle.clone()
8372 }
8373}
8374
8375impl Render for TestProjectItemView {
8376 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
8377 Empty
8378 }
8379}