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