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