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