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_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4417 init_test(cx);
4418
4419 let fs = FakeFs::new(cx.executor());
4420 fs.insert_tree(
4421 "/root_a",
4422 json!({
4423 "src": {
4424 "lib.rs": "",
4425 "main.rs": ""
4426 },
4427 "docs": {
4428 "guide.md": ""
4429 },
4430 "multi": {
4431 "alpha.txt": "",
4432 "beta.txt": ""
4433 }
4434 }),
4435 )
4436 .await;
4437 fs.insert_tree(
4438 "/root_b",
4439 json!({
4440 "dst": {
4441 "existing.md": ""
4442 },
4443 "target.txt": ""
4444 }),
4445 )
4446 .await;
4447
4448 let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4449 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4450 let workspace = window
4451 .read_with(cx, |mw, _| mw.workspace().clone())
4452 .unwrap();
4453 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4454 let panel = workspace.update_in(cx, ProjectPanel::new);
4455 cx.run_until_parked();
4456
4457 // Case 1: move a file onto a directory in another worktree.
4458 select_path(&panel, "root_a/src/main.rs", cx);
4459 drag_selection_to(&panel, "root_b/dst", false, cx);
4460 assert!(
4461 find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4462 "Dragged file should appear under destination worktree"
4463 );
4464 assert_eq!(
4465 find_project_entry(&panel, "root_a/src/main.rs", cx),
4466 None,
4467 "Dragged file should be removed from the source worktree"
4468 );
4469
4470 // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4471 select_path(&panel, "root_a/docs/guide.md", cx);
4472 drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4473 assert!(
4474 find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4475 "Dropping onto a file should place the entry beside the target file"
4476 );
4477 assert_eq!(
4478 find_project_entry(&panel, "root_a/docs/guide.md", cx),
4479 None,
4480 "Source file should be removed after the move"
4481 );
4482
4483 // Case 3: move an entire directory.
4484 select_path(&panel, "root_a/src", cx);
4485 drag_selection_to(&panel, "root_b/dst", false, cx);
4486 assert!(
4487 find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4488 "Dragging a directory should move its nested contents"
4489 );
4490 assert_eq!(
4491 find_project_entry(&panel, "root_a/src", cx),
4492 None,
4493 "Directory should no longer exist in the source worktree"
4494 );
4495
4496 // Case 4: multi-selection drag between worktrees.
4497 panel.update(cx, |panel, _| panel.marked_entries.clear());
4498 select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4499 select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4500 drag_selection_to(&panel, "root_b/dst", false, cx);
4501 assert!(
4502 find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4503 && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4504 "All marked entries should move to the destination worktree"
4505 );
4506 assert_eq!(
4507 find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4508 None,
4509 "Marked entries should be removed from the origin worktree"
4510 );
4511 assert_eq!(
4512 find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4513 None,
4514 "Marked entries should be removed from the origin worktree"
4515 );
4516}
4517
4518#[gpui::test]
4519async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4520 init_test(cx);
4521
4522 let fs = FakeFs::new(cx.executor());
4523 fs.insert_tree(
4524 "/root",
4525 json!({
4526 "src": {
4527 "folder1": {
4528 "mod.rs": "// folder1 mod"
4529 },
4530 "folder2": {
4531 "mod.rs": "// folder2 mod"
4532 },
4533 "folder3": {
4534 "mod.rs": "// folder3 mod",
4535 "helper.rs": "// helper"
4536 },
4537 "main.rs": ""
4538 }
4539 }),
4540 )
4541 .await;
4542
4543 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4544 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4545 let workspace = window
4546 .read_with(cx, |mw, _| mw.workspace().clone())
4547 .unwrap();
4548 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4549 let panel = workspace.update_in(cx, ProjectPanel::new);
4550 cx.run_until_parked();
4551
4552 toggle_expand_dir(&panel, "root/src", cx);
4553 toggle_expand_dir(&panel, "root/src/folder1", cx);
4554 toggle_expand_dir(&panel, "root/src/folder2", cx);
4555 toggle_expand_dir(&panel, "root/src/folder3", cx);
4556 cx.run_until_parked();
4557
4558 // Case 1: Dragging a folder and a file from a sibling folder together.
4559 panel.update(cx, |panel, _| panel.marked_entries.clear());
4560 select_path_with_mark(&panel, "root/src/folder1", cx);
4561 select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4562
4563 drag_selection_to(&panel, "root", false, cx);
4564
4565 assert!(
4566 find_project_entry(&panel, "root/folder1", cx).is_some(),
4567 "folder1 should be at root after drag"
4568 );
4569 assert!(
4570 find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4571 "folder1/mod.rs should still be inside folder1 after drag"
4572 );
4573 assert_eq!(
4574 find_project_entry(&panel, "root/src/folder1", cx),
4575 None,
4576 "folder1 should no longer be in src"
4577 );
4578 assert!(
4579 find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4580 "mod.rs from folder2 should be at root"
4581 );
4582
4583 // Case 2: Dragging a folder and its own child together.
4584 panel.update(cx, |panel, _| panel.marked_entries.clear());
4585 select_path_with_mark(&panel, "root/src/folder3", cx);
4586 select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4587
4588 drag_selection_to(&panel, "root", false, cx);
4589
4590 assert!(
4591 find_project_entry(&panel, "root/folder3", cx).is_some(),
4592 "folder3 should be at root after drag"
4593 );
4594 assert!(
4595 find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4596 "folder3/mod.rs should still be inside folder3"
4597 );
4598 assert!(
4599 find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4600 "folder3/helper.rs should still be inside folder3"
4601 );
4602}
4603
4604#[gpui::test]
4605async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4606 init_test_with_editor(cx);
4607 cx.update(|cx| {
4608 cx.update_global::<SettingsStore, _>(|store, cx| {
4609 store.update_user_settings(cx, |settings| {
4610 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4611 settings
4612 .project_panel
4613 .get_or_insert_default()
4614 .auto_reveal_entries = Some(false);
4615 });
4616 })
4617 });
4618
4619 let fs = FakeFs::new(cx.background_executor.clone());
4620 fs.insert_tree(
4621 "/project_root",
4622 json!({
4623 ".git": {},
4624 ".gitignore": "**/gitignored_dir",
4625 "dir_1": {
4626 "file_1.py": "# File 1_1 contents",
4627 "file_2.py": "# File 1_2 contents",
4628 "file_3.py": "# File 1_3 contents",
4629 "gitignored_dir": {
4630 "file_a.py": "# File contents",
4631 "file_b.py": "# File contents",
4632 "file_c.py": "# File contents",
4633 },
4634 },
4635 "dir_2": {
4636 "file_1.py": "# File 2_1 contents",
4637 "file_2.py": "# File 2_2 contents",
4638 "file_3.py": "# File 2_3 contents",
4639 }
4640 }),
4641 )
4642 .await;
4643
4644 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4645 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4646 let workspace = window
4647 .read_with(cx, |mw, _| mw.workspace().clone())
4648 .unwrap();
4649 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4650 let panel = workspace.update_in(cx, ProjectPanel::new);
4651 cx.run_until_parked();
4652
4653 assert_eq!(
4654 visible_entries_as_strings(&panel, 0..20, cx),
4655 &[
4656 "v project_root",
4657 " > .git",
4658 " > dir_1",
4659 " > dir_2",
4660 " .gitignore",
4661 ]
4662 );
4663
4664 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4665 .expect("dir 1 file is not ignored and should have an entry");
4666 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4667 .expect("dir 2 file is not ignored and should have an entry");
4668 let gitignored_dir_file =
4669 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4670 assert_eq!(
4671 gitignored_dir_file, None,
4672 "File in the gitignored dir should not have an entry before its dir is toggled"
4673 );
4674
4675 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4676 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4677 cx.executor().run_until_parked();
4678 assert_eq!(
4679 visible_entries_as_strings(&panel, 0..20, cx),
4680 &[
4681 "v project_root",
4682 " > .git",
4683 " v dir_1",
4684 " v gitignored_dir <== selected",
4685 " file_a.py",
4686 " file_b.py",
4687 " file_c.py",
4688 " file_1.py",
4689 " file_2.py",
4690 " file_3.py",
4691 " > dir_2",
4692 " .gitignore",
4693 ],
4694 "Should show gitignored dir file list in the project panel"
4695 );
4696 let gitignored_dir_file =
4697 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4698 .expect("after gitignored dir got opened, a file entry should be present");
4699
4700 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4701 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4702 assert_eq!(
4703 visible_entries_as_strings(&panel, 0..20, cx),
4704 &[
4705 "v project_root",
4706 " > .git",
4707 " > dir_1 <== selected",
4708 " > dir_2",
4709 " .gitignore",
4710 ],
4711 "Should hide all dir contents again and prepare for the auto reveal test"
4712 );
4713
4714 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4715 panel.update(cx, |panel, cx| {
4716 panel.project.update(cx, |_, cx| {
4717 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4718 })
4719 });
4720 cx.run_until_parked();
4721 assert_eq!(
4722 visible_entries_as_strings(&panel, 0..20, cx),
4723 &[
4724 "v project_root",
4725 " > .git",
4726 " > dir_1 <== selected",
4727 " > dir_2",
4728 " .gitignore",
4729 ],
4730 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4731 );
4732 }
4733
4734 cx.update(|_, cx| {
4735 cx.update_global::<SettingsStore, _>(|store, cx| {
4736 store.update_user_settings(cx, |settings| {
4737 settings
4738 .project_panel
4739 .get_or_insert_default()
4740 .auto_reveal_entries = Some(true)
4741 });
4742 })
4743 });
4744
4745 panel.update(cx, |panel, cx| {
4746 panel.project.update(cx, |_, cx| {
4747 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4748 })
4749 });
4750 cx.run_until_parked();
4751 assert_eq!(
4752 visible_entries_as_strings(&panel, 0..20, cx),
4753 &[
4754 "v project_root",
4755 " > .git",
4756 " v dir_1",
4757 " > gitignored_dir",
4758 " file_1.py <== selected <== marked",
4759 " file_2.py",
4760 " file_3.py",
4761 " > dir_2",
4762 " .gitignore",
4763 ],
4764 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4765 );
4766
4767 panel.update(cx, |panel, cx| {
4768 panel.project.update(cx, |_, cx| {
4769 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4770 })
4771 });
4772 cx.run_until_parked();
4773 assert_eq!(
4774 visible_entries_as_strings(&panel, 0..20, cx),
4775 &[
4776 "v project_root",
4777 " > .git",
4778 " v dir_1",
4779 " > gitignored_dir",
4780 " file_1.py",
4781 " file_2.py",
4782 " file_3.py",
4783 " v dir_2",
4784 " file_1.py <== selected <== marked",
4785 " file_2.py",
4786 " file_3.py",
4787 " .gitignore",
4788 ],
4789 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4790 );
4791
4792 panel.update(cx, |panel, cx| {
4793 panel.project.update(cx, |_, cx| {
4794 cx.emit(project::Event::ActiveEntryChanged(Some(
4795 gitignored_dir_file,
4796 )))
4797 })
4798 });
4799 cx.run_until_parked();
4800 assert_eq!(
4801 visible_entries_as_strings(&panel, 0..20, cx),
4802 &[
4803 "v project_root",
4804 " > .git",
4805 " v dir_1",
4806 " > gitignored_dir",
4807 " file_1.py",
4808 " file_2.py",
4809 " file_3.py",
4810 " v dir_2",
4811 " file_1.py <== selected <== marked",
4812 " file_2.py",
4813 " file_3.py",
4814 " .gitignore",
4815 ],
4816 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4817 );
4818
4819 panel.update(cx, |panel, cx| {
4820 panel.project.update(cx, |_, cx| {
4821 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4822 })
4823 });
4824 cx.run_until_parked();
4825 assert_eq!(
4826 visible_entries_as_strings(&panel, 0..20, cx),
4827 &[
4828 "v project_root",
4829 " > .git",
4830 " v dir_1",
4831 " v gitignored_dir",
4832 " file_a.py <== selected <== marked",
4833 " file_b.py",
4834 " file_c.py",
4835 " file_1.py",
4836 " file_2.py",
4837 " file_3.py",
4838 " v dir_2",
4839 " file_1.py",
4840 " file_2.py",
4841 " file_3.py",
4842 " .gitignore",
4843 ],
4844 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4845 );
4846
4847 panel.update(cx, |panel, cx| {
4848 panel.project.update(cx, |_, cx| {
4849 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4850 })
4851 });
4852 cx.run_until_parked();
4853 assert_eq!(
4854 visible_entries_as_strings(&panel, 0..20, cx),
4855 &[
4856 "v project_root",
4857 " > .git",
4858 " v dir_1",
4859 " v gitignored_dir",
4860 " file_a.py",
4861 " file_b.py",
4862 " file_c.py",
4863 " file_1.py",
4864 " file_2.py",
4865 " file_3.py",
4866 " v dir_2",
4867 " file_1.py <== selected <== marked",
4868 " file_2.py",
4869 " file_3.py",
4870 " .gitignore",
4871 ],
4872 "After switching to dir_2_file, it should be selected and marked"
4873 );
4874
4875 panel.update(cx, |panel, cx| {
4876 panel.project.update(cx, |_, cx| {
4877 cx.emit(project::Event::ActiveEntryChanged(Some(
4878 gitignored_dir_file,
4879 )))
4880 })
4881 });
4882 cx.run_until_parked();
4883 assert_eq!(
4884 visible_entries_as_strings(&panel, 0..20, cx),
4885 &[
4886 "v project_root",
4887 " > .git",
4888 " v dir_1",
4889 " v gitignored_dir",
4890 " file_a.py <== selected <== marked",
4891 " file_b.py",
4892 " file_c.py",
4893 " file_1.py",
4894 " file_2.py",
4895 " file_3.py",
4896 " v dir_2",
4897 " file_1.py",
4898 " file_2.py",
4899 " file_3.py",
4900 " .gitignore",
4901 ],
4902 "When a gitignored entry is already visible, auto reveal should mark it as selected"
4903 );
4904}
4905
4906#[gpui::test]
4907async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
4908 init_test_with_editor(cx);
4909 cx.update(|cx| {
4910 cx.update_global::<SettingsStore, _>(|store, cx| {
4911 store.update_user_settings(cx, |settings| {
4912 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4913 settings.project.worktree.file_scan_inclusions =
4914 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
4915 settings
4916 .project_panel
4917 .get_or_insert_default()
4918 .auto_reveal_entries = Some(false)
4919 });
4920 })
4921 });
4922
4923 let fs = FakeFs::new(cx.background_executor.clone());
4924 fs.insert_tree(
4925 "/project_root",
4926 json!({
4927 ".git": {},
4928 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
4929 "dir_1": {
4930 "file_1.py": "# File 1_1 contents",
4931 "file_2.py": "# File 1_2 contents",
4932 "file_3.py": "# File 1_3 contents",
4933 "gitignored_dir": {
4934 "file_a.py": "# File contents",
4935 "file_b.py": "# File contents",
4936 "file_c.py": "# File contents",
4937 },
4938 },
4939 "dir_2": {
4940 "file_1.py": "# File 2_1 contents",
4941 "file_2.py": "# File 2_2 contents",
4942 "file_3.py": "# File 2_3 contents",
4943 },
4944 "always_included_but_ignored_dir": {
4945 "file_a.py": "# File contents",
4946 "file_b.py": "# File contents",
4947 "file_c.py": "# File contents",
4948 },
4949 }),
4950 )
4951 .await;
4952
4953 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4954 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4955 let workspace = window
4956 .read_with(cx, |mw, _| mw.workspace().clone())
4957 .unwrap();
4958 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4959 let panel = workspace.update_in(cx, ProjectPanel::new);
4960 cx.run_until_parked();
4961
4962 assert_eq!(
4963 visible_entries_as_strings(&panel, 0..20, cx),
4964 &[
4965 "v project_root",
4966 " > .git",
4967 " > always_included_but_ignored_dir",
4968 " > dir_1",
4969 " > dir_2",
4970 " .gitignore",
4971 ]
4972 );
4973
4974 let gitignored_dir_file =
4975 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4976 let always_included_but_ignored_dir_file = find_project_entry(
4977 &panel,
4978 "project_root/always_included_but_ignored_dir/file_a.py",
4979 cx,
4980 )
4981 .expect("file that is .gitignored but set to always be included should have an entry");
4982 assert_eq!(
4983 gitignored_dir_file, None,
4984 "File in the gitignored dir should not have an entry unless its directory is toggled"
4985 );
4986
4987 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4988 cx.run_until_parked();
4989 cx.update(|_, cx| {
4990 cx.update_global::<SettingsStore, _>(|store, cx| {
4991 store.update_user_settings(cx, |settings| {
4992 settings
4993 .project_panel
4994 .get_or_insert_default()
4995 .auto_reveal_entries = Some(true)
4996 });
4997 })
4998 });
4999
5000 panel.update(cx, |panel, cx| {
5001 panel.project.update(cx, |_, cx| {
5002 cx.emit(project::Event::ActiveEntryChanged(Some(
5003 always_included_but_ignored_dir_file,
5004 )))
5005 })
5006 });
5007 cx.run_until_parked();
5008
5009 assert_eq!(
5010 visible_entries_as_strings(&panel, 0..20, cx),
5011 &[
5012 "v project_root",
5013 " > .git",
5014 " v always_included_but_ignored_dir",
5015 " file_a.py <== selected <== marked",
5016 " file_b.py",
5017 " file_c.py",
5018 " v dir_1",
5019 " > gitignored_dir",
5020 " file_1.py",
5021 " file_2.py",
5022 " file_3.py",
5023 " > dir_2",
5024 " .gitignore",
5025 ],
5026 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
5027 );
5028}
5029
5030#[gpui::test]
5031async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5032 init_test_with_editor(cx);
5033 cx.update(|cx| {
5034 cx.update_global::<SettingsStore, _>(|store, cx| {
5035 store.update_user_settings(cx, |settings| {
5036 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5037 settings
5038 .project_panel
5039 .get_or_insert_default()
5040 .auto_reveal_entries = Some(false)
5041 });
5042 })
5043 });
5044
5045 let fs = FakeFs::new(cx.background_executor.clone());
5046 fs.insert_tree(
5047 "/project_root",
5048 json!({
5049 ".git": {},
5050 ".gitignore": "**/gitignored_dir",
5051 "dir_1": {
5052 "file_1.py": "# File 1_1 contents",
5053 "file_2.py": "# File 1_2 contents",
5054 "file_3.py": "# File 1_3 contents",
5055 "gitignored_dir": {
5056 "file_a.py": "# File contents",
5057 "file_b.py": "# File contents",
5058 "file_c.py": "# File contents",
5059 },
5060 },
5061 "dir_2": {
5062 "file_1.py": "# File 2_1 contents",
5063 "file_2.py": "# File 2_2 contents",
5064 "file_3.py": "# File 2_3 contents",
5065 }
5066 }),
5067 )
5068 .await;
5069
5070 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5071 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5072 let workspace = window
5073 .read_with(cx, |mw, _| mw.workspace().clone())
5074 .unwrap();
5075 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5076 let panel = workspace.update_in(cx, ProjectPanel::new);
5077 cx.run_until_parked();
5078
5079 assert_eq!(
5080 visible_entries_as_strings(&panel, 0..20, cx),
5081 &[
5082 "v project_root",
5083 " > .git",
5084 " > dir_1",
5085 " > dir_2",
5086 " .gitignore",
5087 ]
5088 );
5089
5090 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5091 .expect("dir 1 file is not ignored and should have an entry");
5092 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5093 .expect("dir 2 file is not ignored and should have an entry");
5094 let gitignored_dir_file =
5095 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5096 assert_eq!(
5097 gitignored_dir_file, None,
5098 "File in the gitignored dir should not have an entry before its dir is toggled"
5099 );
5100
5101 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5102 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5103 cx.run_until_parked();
5104 assert_eq!(
5105 visible_entries_as_strings(&panel, 0..20, cx),
5106 &[
5107 "v project_root",
5108 " > .git",
5109 " v dir_1",
5110 " v gitignored_dir <== selected",
5111 " file_a.py",
5112 " file_b.py",
5113 " file_c.py",
5114 " file_1.py",
5115 " file_2.py",
5116 " file_3.py",
5117 " > dir_2",
5118 " .gitignore",
5119 ],
5120 "Should show gitignored dir file list in the project panel"
5121 );
5122 let gitignored_dir_file =
5123 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5124 .expect("after gitignored dir got opened, a file entry should be present");
5125
5126 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5127 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5128 assert_eq!(
5129 visible_entries_as_strings(&panel, 0..20, cx),
5130 &[
5131 "v project_root",
5132 " > .git",
5133 " > dir_1 <== selected",
5134 " > dir_2",
5135 " .gitignore",
5136 ],
5137 "Should hide all dir contents again and prepare for the explicit reveal test"
5138 );
5139
5140 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5141 panel.update(cx, |panel, cx| {
5142 panel.project.update(cx, |_, cx| {
5143 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5144 })
5145 });
5146 cx.run_until_parked();
5147 assert_eq!(
5148 visible_entries_as_strings(&panel, 0..20, cx),
5149 &[
5150 "v project_root",
5151 " > .git",
5152 " > dir_1 <== selected",
5153 " > dir_2",
5154 " .gitignore",
5155 ],
5156 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5157 );
5158 }
5159
5160 panel.update(cx, |panel, cx| {
5161 panel.project.update(cx, |_, cx| {
5162 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5163 })
5164 });
5165 cx.run_until_parked();
5166 assert_eq!(
5167 visible_entries_as_strings(&panel, 0..20, cx),
5168 &[
5169 "v project_root",
5170 " > .git",
5171 " v dir_1",
5172 " > gitignored_dir",
5173 " file_1.py <== selected <== marked",
5174 " file_2.py",
5175 " file_3.py",
5176 " > dir_2",
5177 " .gitignore",
5178 ],
5179 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5180 );
5181
5182 panel.update(cx, |panel, cx| {
5183 panel.project.update(cx, |_, cx| {
5184 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5185 })
5186 });
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 " > gitignored_dir",
5195 " file_1.py",
5196 " file_2.py",
5197 " file_3.py",
5198 " v dir_2",
5199 " file_1.py <== selected <== marked",
5200 " file_2.py",
5201 " file_3.py",
5202 " .gitignore",
5203 ],
5204 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5205 );
5206
5207 panel.update(cx, |panel, cx| {
5208 panel.project.update(cx, |_, cx| {
5209 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5210 })
5211 });
5212 cx.run_until_parked();
5213 assert_eq!(
5214 visible_entries_as_strings(&panel, 0..20, cx),
5215 &[
5216 "v project_root",
5217 " > .git",
5218 " v dir_1",
5219 " v gitignored_dir",
5220 " file_a.py <== selected <== marked",
5221 " file_b.py",
5222 " file_c.py",
5223 " file_1.py",
5224 " file_2.py",
5225 " file_3.py",
5226 " v dir_2",
5227 " file_1.py",
5228 " file_2.py",
5229 " file_3.py",
5230 " .gitignore",
5231 ],
5232 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5233 );
5234}
5235
5236#[gpui::test]
5237async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5238 init_test(cx);
5239 cx.update(|cx| {
5240 cx.update_global::<SettingsStore, _>(|store, cx| {
5241 store.update_user_settings(cx, |settings| {
5242 settings.project.worktree.file_scan_exclusions =
5243 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5244 });
5245 });
5246 });
5247
5248 cx.update(|cx| {
5249 register_project_item::<TestProjectItemView>(cx);
5250 });
5251
5252 let fs = FakeFs::new(cx.executor());
5253 fs.insert_tree(
5254 "/root1",
5255 json!({
5256 ".dockerignore": "",
5257 ".git": {
5258 "HEAD": "",
5259 },
5260 }),
5261 )
5262 .await;
5263
5264 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5265 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5266 let workspace = window
5267 .read_with(cx, |mw, _| mw.workspace().clone())
5268 .unwrap();
5269 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5270 let panel = workspace.update_in(cx, |workspace, window, cx| {
5271 let panel = ProjectPanel::new(workspace, window, cx);
5272 workspace.add_panel(panel.clone(), window, cx);
5273 panel
5274 });
5275 cx.run_until_parked();
5276
5277 select_path(&panel, "root1", cx);
5278 assert_eq!(
5279 visible_entries_as_strings(&panel, 0..10, cx),
5280 &["v root1 <== selected", " .dockerignore",]
5281 );
5282 workspace.update_in(cx, |workspace, _, cx| {
5283 assert!(
5284 workspace.active_item(cx).is_none(),
5285 "Should have no active items in the beginning"
5286 );
5287 });
5288
5289 let excluded_file_path = ".git/COMMIT_EDITMSG";
5290 let excluded_dir_path = "excluded_dir";
5291
5292 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5293 cx.run_until_parked();
5294 panel.update_in(cx, |panel, window, cx| {
5295 assert!(panel.filename_editor.read(cx).is_focused(window));
5296 });
5297 panel
5298 .update_in(cx, |panel, window, cx| {
5299 panel.filename_editor.update(cx, |editor, cx| {
5300 editor.set_text(excluded_file_path, window, cx)
5301 });
5302 panel.confirm_edit(true, window, cx).unwrap()
5303 })
5304 .await
5305 .unwrap();
5306
5307 assert_eq!(
5308 visible_entries_as_strings(&panel, 0..13, cx),
5309 &["v root1", " .dockerignore"],
5310 "Excluded dir should not be shown after opening a file in it"
5311 );
5312 panel.update_in(cx, |panel, window, cx| {
5313 assert!(
5314 !panel.filename_editor.read(cx).is_focused(window),
5315 "Should have closed the file name editor"
5316 );
5317 });
5318 workspace.update_in(cx, |workspace, _, cx| {
5319 let active_entry_path = workspace
5320 .active_item(cx)
5321 .expect("should have opened and activated the excluded item")
5322 .act_as::<TestProjectItemView>(cx)
5323 .expect("should have opened the corresponding project item for the excluded item")
5324 .read(cx)
5325 .path
5326 .clone();
5327 assert_eq!(
5328 active_entry_path.path.as_ref(),
5329 rel_path(excluded_file_path),
5330 "Should open the excluded file"
5331 );
5332
5333 assert!(
5334 workspace.notification_ids().is_empty(),
5335 "Should have no notifications after opening an excluded file"
5336 );
5337 });
5338 assert!(
5339 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5340 "Should have created the excluded file"
5341 );
5342
5343 select_path(&panel, "root1", cx);
5344 panel.update_in(cx, |panel, window, cx| {
5345 panel.new_directory(&NewDirectory, window, cx)
5346 });
5347 cx.run_until_parked();
5348 panel.update_in(cx, |panel, window, cx| {
5349 assert!(panel.filename_editor.read(cx).is_focused(window));
5350 });
5351 panel
5352 .update_in(cx, |panel, window, cx| {
5353 panel.filename_editor.update(cx, |editor, cx| {
5354 editor.set_text(excluded_file_path, window, cx)
5355 });
5356 panel.confirm_edit(true, window, cx).unwrap()
5357 })
5358 .await
5359 .unwrap();
5360 cx.run_until_parked();
5361 assert_eq!(
5362 visible_entries_as_strings(&panel, 0..13, cx),
5363 &["v root1", " .dockerignore"],
5364 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5365 );
5366 panel.update_in(cx, |panel, window, cx| {
5367 assert!(
5368 !panel.filename_editor.read(cx).is_focused(window),
5369 "Should have closed the file name editor"
5370 );
5371 });
5372 workspace.update_in(cx, |workspace, _, cx| {
5373 let notifications = workspace.notification_ids();
5374 assert_eq!(
5375 notifications.len(),
5376 1,
5377 "Should receive one notification with the error message"
5378 );
5379 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5380 assert!(workspace.notification_ids().is_empty());
5381 });
5382
5383 select_path(&panel, "root1", cx);
5384 panel.update_in(cx, |panel, window, cx| {
5385 panel.new_directory(&NewDirectory, window, cx)
5386 });
5387 cx.run_until_parked();
5388
5389 panel.update_in(cx, |panel, window, cx| {
5390 assert!(panel.filename_editor.read(cx).is_focused(window));
5391 });
5392
5393 panel
5394 .update_in(cx, |panel, window, cx| {
5395 panel.filename_editor.update(cx, |editor, cx| {
5396 editor.set_text(excluded_dir_path, window, cx)
5397 });
5398 panel.confirm_edit(true, window, cx).unwrap()
5399 })
5400 .await
5401 .unwrap();
5402
5403 cx.run_until_parked();
5404
5405 assert_eq!(
5406 visible_entries_as_strings(&panel, 0..13, cx),
5407 &["v root1", " .dockerignore"],
5408 "Should not change the project panel after trying to create an excluded directory"
5409 );
5410 panel.update_in(cx, |panel, window, cx| {
5411 assert!(
5412 !panel.filename_editor.read(cx).is_focused(window),
5413 "Should have closed the file name editor"
5414 );
5415 });
5416 workspace.update_in(cx, |workspace, _, cx| {
5417 let notifications = workspace.notification_ids();
5418 assert_eq!(
5419 notifications.len(),
5420 1,
5421 "Should receive one notification explaining that no directory is actually shown"
5422 );
5423 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5424 assert!(workspace.notification_ids().is_empty());
5425 });
5426 assert!(
5427 fs.is_dir(Path::new("/root1/excluded_dir")).await,
5428 "Should have created the excluded directory"
5429 );
5430}
5431
5432#[gpui::test]
5433async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5434 init_test_with_editor(cx);
5435
5436 let fs = FakeFs::new(cx.executor());
5437 fs.insert_tree(
5438 "/src",
5439 json!({
5440 "test": {
5441 "first.rs": "// First Rust file",
5442 "second.rs": "// Second Rust file",
5443 "third.rs": "// Third Rust file",
5444 }
5445 }),
5446 )
5447 .await;
5448
5449 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5450 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5451 let workspace = window
5452 .read_with(cx, |mw, _| mw.workspace().clone())
5453 .unwrap();
5454 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5455 let panel = workspace.update_in(cx, |workspace, window, cx| {
5456 let panel = ProjectPanel::new(workspace, window, cx);
5457 workspace.add_panel(panel.clone(), window, cx);
5458 panel
5459 });
5460 cx.run_until_parked();
5461
5462 select_path(&panel, "src", cx);
5463 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5464 cx.executor().run_until_parked();
5465 assert_eq!(
5466 visible_entries_as_strings(&panel, 0..10, cx),
5467 &[
5468 //
5469 "v src <== selected",
5470 " > test"
5471 ]
5472 );
5473 panel.update_in(cx, |panel, window, cx| {
5474 panel.new_directory(&NewDirectory, window, cx)
5475 });
5476 cx.executor().run_until_parked();
5477 panel.update_in(cx, |panel, window, cx| {
5478 assert!(panel.filename_editor.read(cx).is_focused(window));
5479 });
5480 assert_eq!(
5481 visible_entries_as_strings(&panel, 0..10, cx),
5482 &[
5483 //
5484 "v src",
5485 " > [EDITOR: ''] <== selected",
5486 " > test"
5487 ]
5488 );
5489
5490 panel.update_in(cx, |panel, window, cx| {
5491 panel.cancel(&menu::Cancel, window, cx);
5492 });
5493 cx.executor().run_until_parked();
5494 assert_eq!(
5495 visible_entries_as_strings(&panel, 0..10, cx),
5496 &[
5497 //
5498 "v src <== selected",
5499 " > test"
5500 ]
5501 );
5502
5503 panel.update_in(cx, |panel, window, cx| {
5504 panel.new_directory(&NewDirectory, window, cx)
5505 });
5506 cx.executor().run_until_parked();
5507 panel.update_in(cx, |panel, window, cx| {
5508 assert!(panel.filename_editor.read(cx).is_focused(window));
5509 });
5510 assert_eq!(
5511 visible_entries_as_strings(&panel, 0..10, cx),
5512 &[
5513 //
5514 "v src",
5515 " > [EDITOR: ''] <== selected",
5516 " > test"
5517 ]
5518 );
5519 workspace.update_in(cx, |_, window, _| window.blur());
5520 cx.executor().run_until_parked();
5521 assert_eq!(
5522 visible_entries_as_strings(&panel, 0..10, cx),
5523 &[
5524 //
5525 "v src <== selected",
5526 " > test"
5527 ]
5528 );
5529}
5530
5531#[gpui::test]
5532async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5533 init_test_with_editor(cx);
5534
5535 let fs = FakeFs::new(cx.executor());
5536 fs.insert_tree(
5537 "/root",
5538 json!({
5539 "dir1": {
5540 "subdir1": {},
5541 "file1.txt": "",
5542 "file2.txt": "",
5543 },
5544 "dir2": {
5545 "subdir2": {},
5546 "file3.txt": "",
5547 "file4.txt": "",
5548 },
5549 "file5.txt": "",
5550 "file6.txt": "",
5551 }),
5552 )
5553 .await;
5554
5555 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5556 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5557 let workspace = window
5558 .read_with(cx, |mw, _| mw.workspace().clone())
5559 .unwrap();
5560 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5561 let panel = workspace.update_in(cx, ProjectPanel::new);
5562 cx.run_until_parked();
5563
5564 toggle_expand_dir(&panel, "root/dir1", cx);
5565 toggle_expand_dir(&panel, "root/dir2", cx);
5566
5567 // Test Case 1: Delete middle file in directory
5568 select_path(&panel, "root/dir1/file1.txt", cx);
5569 assert_eq!(
5570 visible_entries_as_strings(&panel, 0..15, cx),
5571 &[
5572 "v root",
5573 " v dir1",
5574 " > subdir1",
5575 " file1.txt <== selected",
5576 " file2.txt",
5577 " v dir2",
5578 " > subdir2",
5579 " file3.txt",
5580 " file4.txt",
5581 " file5.txt",
5582 " file6.txt",
5583 ],
5584 "Initial state before deleting middle file"
5585 );
5586
5587 submit_deletion(&panel, cx);
5588 assert_eq!(
5589 visible_entries_as_strings(&panel, 0..15, cx),
5590 &[
5591 "v root",
5592 " v dir1",
5593 " > subdir1",
5594 " file2.txt <== selected",
5595 " v dir2",
5596 " > subdir2",
5597 " file3.txt",
5598 " file4.txt",
5599 " file5.txt",
5600 " file6.txt",
5601 ],
5602 "Should select next file after deleting middle file"
5603 );
5604
5605 // Test Case 2: Delete last file in directory
5606 submit_deletion(&panel, cx);
5607 assert_eq!(
5608 visible_entries_as_strings(&panel, 0..15, cx),
5609 &[
5610 "v root",
5611 " v dir1",
5612 " > subdir1 <== selected",
5613 " v dir2",
5614 " > subdir2",
5615 " file3.txt",
5616 " file4.txt",
5617 " file5.txt",
5618 " file6.txt",
5619 ],
5620 "Should select next directory when last file is deleted"
5621 );
5622
5623 // Test Case 3: Delete root level file
5624 select_path(&panel, "root/file6.txt", cx);
5625 assert_eq!(
5626 visible_entries_as_strings(&panel, 0..15, cx),
5627 &[
5628 "v root",
5629 " v dir1",
5630 " > subdir1",
5631 " v dir2",
5632 " > subdir2",
5633 " file3.txt",
5634 " file4.txt",
5635 " file5.txt",
5636 " file6.txt <== selected",
5637 ],
5638 "Initial state before deleting root level file"
5639 );
5640
5641 submit_deletion(&panel, cx);
5642 assert_eq!(
5643 visible_entries_as_strings(&panel, 0..15, cx),
5644 &[
5645 "v root",
5646 " v dir1",
5647 " > subdir1",
5648 " v dir2",
5649 " > subdir2",
5650 " file3.txt",
5651 " file4.txt",
5652 " file5.txt <== selected",
5653 ],
5654 "Should select prev entry at root level"
5655 );
5656}
5657
5658#[gpui::test]
5659async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
5660 init_test_with_editor(cx);
5661
5662 let fs = FakeFs::new(cx.executor());
5663 fs.insert_tree(
5664 path!("/root"),
5665 json!({
5666 "aa": "// Testing 1",
5667 "bb": "// Testing 2",
5668 "cc": "// Testing 3",
5669 "dd": "// Testing 4",
5670 "ee": "// Testing 5",
5671 "ff": "// Testing 6",
5672 "gg": "// Testing 7",
5673 "hh": "// Testing 8",
5674 "ii": "// Testing 8",
5675 ".gitignore": "bb\ndd\nee\nff\nii\n'",
5676 }),
5677 )
5678 .await;
5679
5680 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5681 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5682 let workspace = window
5683 .read_with(cx, |mw, _| mw.workspace().clone())
5684 .unwrap();
5685 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5686
5687 // Test 1: Auto selection with one gitignored file next to the deleted file
5688 cx.update(|_, cx| {
5689 let settings = *ProjectPanelSettings::get_global(cx);
5690 ProjectPanelSettings::override_global(
5691 ProjectPanelSettings {
5692 hide_gitignore: true,
5693 ..settings
5694 },
5695 cx,
5696 );
5697 });
5698
5699 let panel = workspace.update_in(cx, ProjectPanel::new);
5700 cx.run_until_parked();
5701
5702 select_path(&panel, "root/aa", cx);
5703 assert_eq!(
5704 visible_entries_as_strings(&panel, 0..10, cx),
5705 &[
5706 "v root",
5707 " .gitignore",
5708 " aa <== selected",
5709 " cc",
5710 " gg",
5711 " hh"
5712 ],
5713 "Initial state should hide files on .gitignore"
5714 );
5715
5716 submit_deletion(&panel, cx);
5717
5718 assert_eq!(
5719 visible_entries_as_strings(&panel, 0..10, cx),
5720 &[
5721 "v root",
5722 " .gitignore",
5723 " cc <== selected",
5724 " gg",
5725 " hh"
5726 ],
5727 "Should select next entry not on .gitignore"
5728 );
5729
5730 // Test 2: Auto selection with many gitignored files next to the deleted file
5731 submit_deletion(&panel, cx);
5732 assert_eq!(
5733 visible_entries_as_strings(&panel, 0..10, cx),
5734 &[
5735 "v root",
5736 " .gitignore",
5737 " gg <== selected",
5738 " hh"
5739 ],
5740 "Should select next entry not on .gitignore"
5741 );
5742
5743 // Test 3: Auto selection of entry before deleted file
5744 select_path(&panel, "root/hh", cx);
5745 assert_eq!(
5746 visible_entries_as_strings(&panel, 0..10, cx),
5747 &[
5748 "v root",
5749 " .gitignore",
5750 " gg",
5751 " hh <== selected"
5752 ],
5753 "Should select next entry not on .gitignore"
5754 );
5755 submit_deletion(&panel, cx);
5756 assert_eq!(
5757 visible_entries_as_strings(&panel, 0..10, cx),
5758 &["v root", " .gitignore", " gg <== selected"],
5759 "Should select next entry not on .gitignore"
5760 );
5761}
5762
5763#[gpui::test]
5764async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
5765 init_test_with_editor(cx);
5766
5767 let fs = FakeFs::new(cx.executor());
5768 fs.insert_tree(
5769 path!("/root"),
5770 json!({
5771 "dir1": {
5772 "file1": "// Testing",
5773 "file2": "// Testing",
5774 "file3": "// Testing"
5775 },
5776 "aa": "// Testing",
5777 ".gitignore": "file1\nfile3\n",
5778 }),
5779 )
5780 .await;
5781
5782 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
5783 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5784 let workspace = window
5785 .read_with(cx, |mw, _| mw.workspace().clone())
5786 .unwrap();
5787 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5788
5789 cx.update(|_, cx| {
5790 let settings = *ProjectPanelSettings::get_global(cx);
5791 ProjectPanelSettings::override_global(
5792 ProjectPanelSettings {
5793 hide_gitignore: true,
5794 ..settings
5795 },
5796 cx,
5797 );
5798 });
5799
5800 let panel = workspace.update_in(cx, ProjectPanel::new);
5801 cx.run_until_parked();
5802
5803 // Test 1: Visible items should exclude files on gitignore
5804 toggle_expand_dir(&panel, "root/dir1", cx);
5805 select_path(&panel, "root/dir1/file2", cx);
5806 assert_eq!(
5807 visible_entries_as_strings(&panel, 0..10, cx),
5808 &[
5809 "v root",
5810 " v dir1",
5811 " file2 <== selected",
5812 " .gitignore",
5813 " aa"
5814 ],
5815 "Initial state should hide files on .gitignore"
5816 );
5817 submit_deletion(&panel, cx);
5818
5819 // Test 2: Auto selection should go to the parent
5820 assert_eq!(
5821 visible_entries_as_strings(&panel, 0..10, cx),
5822 &[
5823 "v root",
5824 " v dir1 <== selected",
5825 " .gitignore",
5826 " aa"
5827 ],
5828 "Initial state should hide files on .gitignore"
5829 );
5830}
5831
5832#[gpui::test]
5833async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
5834 init_test_with_editor(cx);
5835
5836 let fs = FakeFs::new(cx.executor());
5837 fs.insert_tree(
5838 "/root",
5839 json!({
5840 "dir1": {
5841 "subdir1": {
5842 "a.txt": "",
5843 "b.txt": ""
5844 },
5845 "file1.txt": "",
5846 },
5847 "dir2": {
5848 "subdir2": {
5849 "c.txt": "",
5850 "d.txt": ""
5851 },
5852 "file2.txt": "",
5853 },
5854 "file3.txt": "",
5855 }),
5856 )
5857 .await;
5858
5859 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5860 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5861 let workspace = window
5862 .read_with(cx, |mw, _| mw.workspace().clone())
5863 .unwrap();
5864 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5865 let panel = workspace.update_in(cx, ProjectPanel::new);
5866 cx.run_until_parked();
5867
5868 toggle_expand_dir(&panel, "root/dir1", cx);
5869 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5870 toggle_expand_dir(&panel, "root/dir2", cx);
5871 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5872
5873 // Test Case 1: Select and delete nested directory with parent
5874 cx.simulate_modifiers_change(gpui::Modifiers {
5875 control: true,
5876 ..Default::default()
5877 });
5878 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
5879 select_path_with_mark(&panel, "root/dir1", cx);
5880
5881 assert_eq!(
5882 visible_entries_as_strings(&panel, 0..15, cx),
5883 &[
5884 "v root",
5885 " v dir1 <== selected <== marked",
5886 " v subdir1 <== marked",
5887 " a.txt",
5888 " b.txt",
5889 " file1.txt",
5890 " v dir2",
5891 " v subdir2",
5892 " c.txt",
5893 " d.txt",
5894 " file2.txt",
5895 " file3.txt",
5896 ],
5897 "Initial state before deleting nested directory with parent"
5898 );
5899
5900 submit_deletion(&panel, cx);
5901 assert_eq!(
5902 visible_entries_as_strings(&panel, 0..15, cx),
5903 &[
5904 "v root",
5905 " v dir2 <== selected",
5906 " v subdir2",
5907 " c.txt",
5908 " d.txt",
5909 " file2.txt",
5910 " file3.txt",
5911 ],
5912 "Should select next directory after deleting directory with parent"
5913 );
5914
5915 // Test Case 2: Select mixed files and directories across levels
5916 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
5917 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
5918 select_path_with_mark(&panel, "root/file3.txt", cx);
5919
5920 assert_eq!(
5921 visible_entries_as_strings(&panel, 0..15, cx),
5922 &[
5923 "v root",
5924 " v dir2",
5925 " v subdir2",
5926 " c.txt <== marked",
5927 " d.txt",
5928 " file2.txt <== marked",
5929 " file3.txt <== selected <== marked",
5930 ],
5931 "Initial state before deleting"
5932 );
5933
5934 submit_deletion(&panel, cx);
5935 assert_eq!(
5936 visible_entries_as_strings(&panel, 0..15, cx),
5937 &[
5938 "v root",
5939 " v dir2 <== selected",
5940 " v subdir2",
5941 " d.txt",
5942 ],
5943 "Should select sibling directory"
5944 );
5945}
5946
5947#[gpui::test]
5948async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
5949 init_test_with_editor(cx);
5950
5951 let fs = FakeFs::new(cx.executor());
5952 fs.insert_tree(
5953 "/root",
5954 json!({
5955 "dir1": {
5956 "subdir1": {
5957 "a.txt": "",
5958 "b.txt": ""
5959 },
5960 "file1.txt": "",
5961 },
5962 "dir2": {
5963 "subdir2": {
5964 "c.txt": "",
5965 "d.txt": ""
5966 },
5967 "file2.txt": "",
5968 },
5969 "file3.txt": "",
5970 "file4.txt": "",
5971 }),
5972 )
5973 .await;
5974
5975 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5976 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5977 let workspace = window
5978 .read_with(cx, |mw, _| mw.workspace().clone())
5979 .unwrap();
5980 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5981 let panel = workspace.update_in(cx, ProjectPanel::new);
5982 cx.run_until_parked();
5983
5984 toggle_expand_dir(&panel, "root/dir1", cx);
5985 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
5986 toggle_expand_dir(&panel, "root/dir2", cx);
5987 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
5988
5989 // Test Case 1: Select all root files and directories
5990 cx.simulate_modifiers_change(gpui::Modifiers {
5991 control: true,
5992 ..Default::default()
5993 });
5994 select_path_with_mark(&panel, "root/dir1", cx);
5995 select_path_with_mark(&panel, "root/dir2", cx);
5996 select_path_with_mark(&panel, "root/file3.txt", cx);
5997 select_path_with_mark(&panel, "root/file4.txt", cx);
5998 assert_eq!(
5999 visible_entries_as_strings(&panel, 0..20, cx),
6000 &[
6001 "v root",
6002 " v dir1 <== marked",
6003 " v subdir1",
6004 " a.txt",
6005 " b.txt",
6006 " file1.txt",
6007 " v dir2 <== marked",
6008 " v subdir2",
6009 " c.txt",
6010 " d.txt",
6011 " file2.txt",
6012 " file3.txt <== marked",
6013 " file4.txt <== selected <== marked",
6014 ],
6015 "State before deleting all contents"
6016 );
6017
6018 submit_deletion(&panel, cx);
6019 assert_eq!(
6020 visible_entries_as_strings(&panel, 0..20, cx),
6021 &["v root <== selected"],
6022 "Only empty root directory should remain after deleting all contents"
6023 );
6024}
6025
6026#[gpui::test]
6027async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
6028 init_test_with_editor(cx);
6029
6030 let fs = FakeFs::new(cx.executor());
6031 fs.insert_tree(
6032 "/root",
6033 json!({
6034 "dir1": {
6035 "subdir1": {
6036 "file_a.txt": "content a",
6037 "file_b.txt": "content b",
6038 },
6039 "subdir2": {
6040 "file_c.txt": "content c",
6041 },
6042 "file1.txt": "content 1",
6043 },
6044 "dir2": {
6045 "file2.txt": "content 2",
6046 },
6047 }),
6048 )
6049 .await;
6050
6051 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6052 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6053 let workspace = window
6054 .read_with(cx, |mw, _| mw.workspace().clone())
6055 .unwrap();
6056 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6057 let panel = workspace.update_in(cx, ProjectPanel::new);
6058 cx.run_until_parked();
6059
6060 toggle_expand_dir(&panel, "root/dir1", cx);
6061 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6062 toggle_expand_dir(&panel, "root/dir2", cx);
6063 cx.simulate_modifiers_change(gpui::Modifiers {
6064 control: true,
6065 ..Default::default()
6066 });
6067
6068 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6069 select_path_with_mark(&panel, "root/dir1", cx);
6070 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6071 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6072
6073 assert_eq!(
6074 visible_entries_as_strings(&panel, 0..20, cx),
6075 &[
6076 "v root",
6077 " v dir1 <== marked",
6078 " v subdir1 <== marked",
6079 " file_a.txt <== selected <== marked",
6080 " file_b.txt",
6081 " > subdir2",
6082 " file1.txt",
6083 " v dir2",
6084 " file2.txt",
6085 ],
6086 "State with parent dir, subdir, and file selected"
6087 );
6088 submit_deletion(&panel, cx);
6089 assert_eq!(
6090 visible_entries_as_strings(&panel, 0..20, cx),
6091 &["v root", " v dir2 <== selected", " file2.txt",],
6092 "Only dir2 should remain after deletion"
6093 );
6094}
6095
6096#[gpui::test]
6097async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
6098 init_test_with_editor(cx);
6099
6100 let fs = FakeFs::new(cx.executor());
6101 // First worktree
6102 fs.insert_tree(
6103 "/root1",
6104 json!({
6105 "dir1": {
6106 "file1.txt": "content 1",
6107 "file2.txt": "content 2",
6108 },
6109 "dir2": {
6110 "file3.txt": "content 3",
6111 },
6112 }),
6113 )
6114 .await;
6115
6116 // Second worktree
6117 fs.insert_tree(
6118 "/root2",
6119 json!({
6120 "dir3": {
6121 "file4.txt": "content 4",
6122 "file5.txt": "content 5",
6123 },
6124 "file6.txt": "content 6",
6125 }),
6126 )
6127 .await;
6128
6129 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6130 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6131 let workspace = window
6132 .read_with(cx, |mw, _| mw.workspace().clone())
6133 .unwrap();
6134 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6135 let panel = workspace.update_in(cx, ProjectPanel::new);
6136 cx.run_until_parked();
6137
6138 // Expand all directories for testing
6139 toggle_expand_dir(&panel, "root1/dir1", cx);
6140 toggle_expand_dir(&panel, "root1/dir2", cx);
6141 toggle_expand_dir(&panel, "root2/dir3", cx);
6142
6143 // Test Case 1: Delete files across different worktrees
6144 cx.simulate_modifiers_change(gpui::Modifiers {
6145 control: true,
6146 ..Default::default()
6147 });
6148 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
6149 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
6150
6151 assert_eq!(
6152 visible_entries_as_strings(&panel, 0..20, cx),
6153 &[
6154 "v root1",
6155 " v dir1",
6156 " file1.txt <== marked",
6157 " file2.txt",
6158 " v dir2",
6159 " file3.txt",
6160 "v root2",
6161 " v dir3",
6162 " file4.txt <== selected <== marked",
6163 " file5.txt",
6164 " file6.txt",
6165 ],
6166 "Initial state with files selected from different worktrees"
6167 );
6168
6169 submit_deletion(&panel, cx);
6170 assert_eq!(
6171 visible_entries_as_strings(&panel, 0..20, cx),
6172 &[
6173 "v root1",
6174 " v dir1",
6175 " file2.txt",
6176 " v dir2",
6177 " file3.txt",
6178 "v root2",
6179 " v dir3",
6180 " file5.txt <== selected",
6181 " file6.txt",
6182 ],
6183 "Should select next file in the last worktree after deletion"
6184 );
6185
6186 // Test Case 2: Delete directories from different worktrees
6187 select_path_with_mark(&panel, "root1/dir1", cx);
6188 select_path_with_mark(&panel, "root2/dir3", cx);
6189
6190 assert_eq!(
6191 visible_entries_as_strings(&panel, 0..20, cx),
6192 &[
6193 "v root1",
6194 " v dir1 <== marked",
6195 " file2.txt",
6196 " v dir2",
6197 " file3.txt",
6198 "v root2",
6199 " v dir3 <== selected <== marked",
6200 " file5.txt",
6201 " file6.txt",
6202 ],
6203 "State with directories marked from different worktrees"
6204 );
6205
6206 submit_deletion(&panel, cx);
6207 assert_eq!(
6208 visible_entries_as_strings(&panel, 0..20, cx),
6209 &[
6210 "v root1",
6211 " v dir2",
6212 " file3.txt",
6213 "v root2",
6214 " file6.txt <== selected",
6215 ],
6216 "Should select remaining file in last worktree after directory deletion"
6217 );
6218
6219 // Test Case 4: Delete all remaining files except roots
6220 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
6221 select_path_with_mark(&panel, "root2/file6.txt", cx);
6222
6223 assert_eq!(
6224 visible_entries_as_strings(&panel, 0..20, cx),
6225 &[
6226 "v root1",
6227 " v dir2",
6228 " file3.txt <== marked",
6229 "v root2",
6230 " file6.txt <== selected <== marked",
6231 ],
6232 "State with all remaining files marked"
6233 );
6234
6235 submit_deletion(&panel, cx);
6236 assert_eq!(
6237 visible_entries_as_strings(&panel, 0..20, cx),
6238 &["v root1", " v dir2", "v root2 <== selected"],
6239 "Second parent root should be selected after deleting"
6240 );
6241}
6242
6243#[gpui::test]
6244async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
6245 init_test_with_editor(cx);
6246
6247 let fs = FakeFs::new(cx.executor());
6248 fs.insert_tree(
6249 "/root",
6250 json!({
6251 "dir1": {
6252 "file1.txt": "",
6253 "file2.txt": "",
6254 "file3.txt": "",
6255 },
6256 "dir2": {
6257 "file4.txt": "",
6258 "file5.txt": "",
6259 },
6260 }),
6261 )
6262 .await;
6263
6264 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6265 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6266 let workspace = window
6267 .read_with(cx, |mw, _| mw.workspace().clone())
6268 .unwrap();
6269 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6270 let panel = workspace.update_in(cx, ProjectPanel::new);
6271 cx.run_until_parked();
6272
6273 toggle_expand_dir(&panel, "root/dir1", cx);
6274 toggle_expand_dir(&panel, "root/dir2", cx);
6275
6276 cx.simulate_modifiers_change(gpui::Modifiers {
6277 control: true,
6278 ..Default::default()
6279 });
6280
6281 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6282 select_path(&panel, "root/dir1/file1.txt", cx);
6283
6284 assert_eq!(
6285 visible_entries_as_strings(&panel, 0..15, cx),
6286 &[
6287 "v root",
6288 " v dir1",
6289 " file1.txt <== selected",
6290 " file2.txt <== marked",
6291 " file3.txt",
6292 " v dir2",
6293 " file4.txt",
6294 " file5.txt",
6295 ],
6296 "Initial state with one marked entry and different selection"
6297 );
6298
6299 // Delete should operate on the selected entry (file1.txt)
6300 submit_deletion(&panel, cx);
6301 assert_eq!(
6302 visible_entries_as_strings(&panel, 0..15, cx),
6303 &[
6304 "v root",
6305 " v dir1",
6306 " file2.txt <== selected <== marked",
6307 " file3.txt",
6308 " v dir2",
6309 " file4.txt",
6310 " file5.txt",
6311 ],
6312 "Should delete selected file, not marked file"
6313 );
6314
6315 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6316 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6317 select_path(&panel, "root/dir2/file5.txt", cx);
6318
6319 assert_eq!(
6320 visible_entries_as_strings(&panel, 0..15, cx),
6321 &[
6322 "v root",
6323 " v dir1",
6324 " file2.txt <== marked",
6325 " file3.txt <== marked",
6326 " v dir2",
6327 " file4.txt <== marked",
6328 " file5.txt <== selected",
6329 ],
6330 "Initial state with multiple marked entries and different selection"
6331 );
6332
6333 // Delete should operate on all marked entries, ignoring the selection
6334 submit_deletion(&panel, cx);
6335 assert_eq!(
6336 visible_entries_as_strings(&panel, 0..15, cx),
6337 &[
6338 "v root",
6339 " v dir1",
6340 " v dir2",
6341 " file5.txt <== selected",
6342 ],
6343 "Should delete all marked files, leaving only the selected file"
6344 );
6345}
6346
6347#[gpui::test]
6348async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6349 init_test_with_editor(cx);
6350
6351 let fs = FakeFs::new(cx.executor());
6352 fs.insert_tree(
6353 "/root_b",
6354 json!({
6355 "dir1": {
6356 "file1.txt": "content 1",
6357 "file2.txt": "content 2",
6358 },
6359 }),
6360 )
6361 .await;
6362
6363 fs.insert_tree(
6364 "/root_c",
6365 json!({
6366 "dir2": {},
6367 }),
6368 )
6369 .await;
6370
6371 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6372 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6373 let workspace = window
6374 .read_with(cx, |mw, _| mw.workspace().clone())
6375 .unwrap();
6376 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6377 let panel = workspace.update_in(cx, ProjectPanel::new);
6378 cx.run_until_parked();
6379
6380 toggle_expand_dir(&panel, "root_b/dir1", cx);
6381 toggle_expand_dir(&panel, "root_c/dir2", cx);
6382
6383 cx.simulate_modifiers_change(gpui::Modifiers {
6384 control: true,
6385 ..Default::default()
6386 });
6387 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6388 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6389
6390 assert_eq!(
6391 visible_entries_as_strings(&panel, 0..20, cx),
6392 &[
6393 "v root_b",
6394 " v dir1",
6395 " file1.txt <== marked",
6396 " file2.txt <== selected <== marked",
6397 "v root_c",
6398 " v dir2",
6399 ],
6400 "Initial state with files marked in root_b"
6401 );
6402
6403 submit_deletion(&panel, cx);
6404 assert_eq!(
6405 visible_entries_as_strings(&panel, 0..20, cx),
6406 &[
6407 "v root_b",
6408 " v dir1 <== selected",
6409 "v root_c",
6410 " v dir2",
6411 ],
6412 "After deletion in root_b as it's last deletion, selection should be in root_b"
6413 );
6414
6415 select_path_with_mark(&panel, "root_c/dir2", cx);
6416
6417 submit_deletion(&panel, cx);
6418 assert_eq!(
6419 visible_entries_as_strings(&panel, 0..20, cx),
6420 &["v root_b", " v dir1", "v root_c <== selected",],
6421 "After deleting from root_c, it should remain in root_c"
6422 );
6423}
6424
6425fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
6426 let path = rel_path(path);
6427 panel.update_in(cx, |panel, window, cx| {
6428 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6429 let worktree = worktree.read(cx);
6430 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6431 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6432 panel.toggle_expanded(entry_id, window, cx);
6433 return;
6434 }
6435 }
6436 panic!("no worktree for path {:?}", path);
6437 });
6438 cx.run_until_parked();
6439}
6440
6441#[gpui::test]
6442async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6443 init_test_with_editor(cx);
6444
6445 let fs = FakeFs::new(cx.executor());
6446 fs.insert_tree(
6447 path!("/root"),
6448 json!({
6449 ".gitignore": "**/ignored_dir\n**/ignored_nested",
6450 "dir1": {
6451 "empty1": {
6452 "empty2": {
6453 "empty3": {
6454 "file.txt": ""
6455 }
6456 }
6457 },
6458 "subdir1": {
6459 "file1.txt": "",
6460 "file2.txt": "",
6461 "ignored_nested": {
6462 "ignored_file.txt": ""
6463 }
6464 },
6465 "ignored_dir": {
6466 "subdir": {
6467 "deep_file.txt": ""
6468 }
6469 }
6470 }
6471 }),
6472 )
6473 .await;
6474
6475 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6476 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6477 let workspace = window
6478 .read_with(cx, |mw, _| mw.workspace().clone())
6479 .unwrap();
6480 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6481
6482 // Test 1: When auto-fold is enabled
6483 cx.update(|_, cx| {
6484 let settings = *ProjectPanelSettings::get_global(cx);
6485 ProjectPanelSettings::override_global(
6486 ProjectPanelSettings {
6487 auto_fold_dirs: true,
6488 ..settings
6489 },
6490 cx,
6491 );
6492 });
6493
6494 let panel = workspace.update_in(cx, ProjectPanel::new);
6495 cx.run_until_parked();
6496
6497 assert_eq!(
6498 visible_entries_as_strings(&panel, 0..20, cx),
6499 &["v root", " > dir1", " .gitignore",],
6500 "Initial state should show collapsed root structure"
6501 );
6502
6503 toggle_expand_dir(&panel, "root/dir1", cx);
6504 assert_eq!(
6505 visible_entries_as_strings(&panel, 0..20, cx),
6506 &[
6507 "v root",
6508 " v dir1 <== selected",
6509 " > empty1/empty2/empty3",
6510 " > ignored_dir",
6511 " > subdir1",
6512 " .gitignore",
6513 ],
6514 "Should show first level with auto-folded dirs and ignored dir visible"
6515 );
6516
6517 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6518 panel.update_in(cx, |panel, window, cx| {
6519 let project = panel.project.read(cx);
6520 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6521 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6522 panel.update_visible_entries(None, false, false, window, cx);
6523 });
6524 cx.run_until_parked();
6525
6526 assert_eq!(
6527 visible_entries_as_strings(&panel, 0..20, cx),
6528 &[
6529 "v root",
6530 " v dir1 <== selected",
6531 " v empty1",
6532 " v empty2",
6533 " v empty3",
6534 " file.txt",
6535 " > ignored_dir",
6536 " v subdir1",
6537 " > ignored_nested",
6538 " file1.txt",
6539 " file2.txt",
6540 " .gitignore",
6541 ],
6542 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6543 );
6544
6545 // Test 2: When auto-fold is disabled
6546 cx.update(|_, cx| {
6547 let settings = *ProjectPanelSettings::get_global(cx);
6548 ProjectPanelSettings::override_global(
6549 ProjectPanelSettings {
6550 auto_fold_dirs: false,
6551 ..settings
6552 },
6553 cx,
6554 );
6555 });
6556
6557 panel.update_in(cx, |panel, window, cx| {
6558 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6559 });
6560
6561 toggle_expand_dir(&panel, "root/dir1", cx);
6562 assert_eq!(
6563 visible_entries_as_strings(&panel, 0..20, cx),
6564 &[
6565 "v root",
6566 " v dir1 <== selected",
6567 " > empty1",
6568 " > ignored_dir",
6569 " > subdir1",
6570 " .gitignore",
6571 ],
6572 "With auto-fold disabled: should show all directories separately"
6573 );
6574
6575 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6576 panel.update_in(cx, |panel, window, cx| {
6577 let project = panel.project.read(cx);
6578 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6579 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6580 panel.update_visible_entries(None, false, false, window, cx);
6581 });
6582 cx.run_until_parked();
6583
6584 assert_eq!(
6585 visible_entries_as_strings(&panel, 0..20, cx),
6586 &[
6587 "v root",
6588 " v dir1 <== selected",
6589 " v empty1",
6590 " v empty2",
6591 " v empty3",
6592 " file.txt",
6593 " > ignored_dir",
6594 " v subdir1",
6595 " > ignored_nested",
6596 " file1.txt",
6597 " file2.txt",
6598 " .gitignore",
6599 ],
6600 "After expand_all without auto-fold: should expand all dirs normally, \
6601 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6602 );
6603
6604 // Test 3: When explicitly called on ignored directory
6605 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6606 panel.update_in(cx, |panel, window, cx| {
6607 let project = panel.project.read(cx);
6608 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6609 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6610 panel.update_visible_entries(None, false, false, window, cx);
6611 });
6612 cx.run_until_parked();
6613
6614 assert_eq!(
6615 visible_entries_as_strings(&panel, 0..20, cx),
6616 &[
6617 "v root",
6618 " v dir1 <== selected",
6619 " v empty1",
6620 " v empty2",
6621 " v empty3",
6622 " file.txt",
6623 " v ignored_dir",
6624 " v subdir",
6625 " deep_file.txt",
6626 " v subdir1",
6627 " > ignored_nested",
6628 " file1.txt",
6629 " file2.txt",
6630 " .gitignore",
6631 ],
6632 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
6633 );
6634}
6635
6636#[gpui::test]
6637async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
6638 init_test(cx);
6639
6640 let fs = FakeFs::new(cx.executor());
6641 fs.insert_tree(
6642 path!("/root"),
6643 json!({
6644 "dir1": {
6645 "subdir1": {
6646 "nested1": {
6647 "file1.txt": "",
6648 "file2.txt": ""
6649 },
6650 },
6651 "subdir2": {
6652 "file4.txt": ""
6653 }
6654 },
6655 "dir2": {
6656 "single_file": {
6657 "file5.txt": ""
6658 }
6659 }
6660 }),
6661 )
6662 .await;
6663
6664 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6665 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6666 let workspace = window
6667 .read_with(cx, |mw, _| mw.workspace().clone())
6668 .unwrap();
6669 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6670
6671 // Test 1: Basic collapsing
6672 {
6673 let panel = workspace.update_in(cx, ProjectPanel::new);
6674 cx.run_until_parked();
6675
6676 toggle_expand_dir(&panel, "root/dir1", cx);
6677 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6678 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6679 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6680
6681 assert_eq!(
6682 visible_entries_as_strings(&panel, 0..20, cx),
6683 &[
6684 "v root",
6685 " v dir1",
6686 " v subdir1",
6687 " v nested1",
6688 " file1.txt",
6689 " file2.txt",
6690 " v subdir2 <== selected",
6691 " file4.txt",
6692 " > dir2",
6693 ],
6694 "Initial state with everything expanded"
6695 );
6696
6697 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6698 panel.update_in(cx, |panel, window, cx| {
6699 let project = panel.project.read(cx);
6700 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6701 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6702 panel.update_visible_entries(None, false, false, window, cx);
6703 });
6704 cx.run_until_parked();
6705
6706 assert_eq!(
6707 visible_entries_as_strings(&panel, 0..20, cx),
6708 &["v root", " > dir1", " > dir2",],
6709 "All subdirs under dir1 should be collapsed"
6710 );
6711 }
6712
6713 // Test 2: With auto-fold enabled
6714 {
6715 cx.update(|_, cx| {
6716 let settings = *ProjectPanelSettings::get_global(cx);
6717 ProjectPanelSettings::override_global(
6718 ProjectPanelSettings {
6719 auto_fold_dirs: true,
6720 ..settings
6721 },
6722 cx,
6723 );
6724 });
6725
6726 let panel = workspace.update_in(cx, ProjectPanel::new);
6727 cx.run_until_parked();
6728
6729 toggle_expand_dir(&panel, "root/dir1", cx);
6730 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6731 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6732
6733 assert_eq!(
6734 visible_entries_as_strings(&panel, 0..20, cx),
6735 &[
6736 "v root",
6737 " v dir1",
6738 " v subdir1/nested1 <== selected",
6739 " file1.txt",
6740 " file2.txt",
6741 " > subdir2",
6742 " > dir2/single_file",
6743 ],
6744 "Initial state with some dirs expanded"
6745 );
6746
6747 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6748 panel.update(cx, |panel, cx| {
6749 let project = panel.project.read(cx);
6750 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6751 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6752 });
6753
6754 toggle_expand_dir(&panel, "root/dir1", cx);
6755
6756 assert_eq!(
6757 visible_entries_as_strings(&panel, 0..20, cx),
6758 &[
6759 "v root",
6760 " v dir1 <== selected",
6761 " > subdir1/nested1",
6762 " > subdir2",
6763 " > dir2/single_file",
6764 ],
6765 "Subdirs should be collapsed and folded with auto-fold enabled"
6766 );
6767 }
6768
6769 // Test 3: With auto-fold disabled
6770 {
6771 cx.update(|_, cx| {
6772 let settings = *ProjectPanelSettings::get_global(cx);
6773 ProjectPanelSettings::override_global(
6774 ProjectPanelSettings {
6775 auto_fold_dirs: false,
6776 ..settings
6777 },
6778 cx,
6779 );
6780 });
6781
6782 let panel = workspace.update_in(cx, ProjectPanel::new);
6783 cx.run_until_parked();
6784
6785 toggle_expand_dir(&panel, "root/dir1", cx);
6786 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6787 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6788
6789 assert_eq!(
6790 visible_entries_as_strings(&panel, 0..20, cx),
6791 &[
6792 "v root",
6793 " v dir1",
6794 " v subdir1",
6795 " v nested1 <== selected",
6796 " file1.txt",
6797 " file2.txt",
6798 " > subdir2",
6799 " > dir2",
6800 ],
6801 "Initial state with some dirs expanded and auto-fold disabled"
6802 );
6803
6804 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6805 panel.update(cx, |panel, cx| {
6806 let project = panel.project.read(cx);
6807 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6808 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
6809 });
6810
6811 toggle_expand_dir(&panel, "root/dir1", cx);
6812
6813 assert_eq!(
6814 visible_entries_as_strings(&panel, 0..20, cx),
6815 &[
6816 "v root",
6817 " v dir1 <== selected",
6818 " > subdir1",
6819 " > subdir2",
6820 " > dir2",
6821 ],
6822 "Subdirs should be collapsed but not folded with auto-fold disabled"
6823 );
6824 }
6825}
6826
6827#[gpui::test]
6828async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
6829 init_test(cx);
6830
6831 let fs = FakeFs::new(cx.executor());
6832 fs.insert_tree(
6833 path!("/root"),
6834 json!({
6835 "dir1": {
6836 "subdir1": {
6837 "nested1": {
6838 "file1.txt": "",
6839 "file2.txt": ""
6840 },
6841 },
6842 "subdir2": {
6843 "file3.txt": ""
6844 }
6845 },
6846 "dir2": {
6847 "file4.txt": ""
6848 }
6849 }),
6850 )
6851 .await;
6852
6853 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6854 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6855 let workspace = window
6856 .read_with(cx, |mw, _| mw.workspace().clone())
6857 .unwrap();
6858 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6859
6860 let panel = workspace.update_in(cx, ProjectPanel::new);
6861 cx.run_until_parked();
6862
6863 toggle_expand_dir(&panel, "root/dir1", cx);
6864 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6865 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
6866 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
6867 toggle_expand_dir(&panel, "root/dir2", cx);
6868
6869 assert_eq!(
6870 visible_entries_as_strings(&panel, 0..20, cx),
6871 &[
6872 "v root",
6873 " v dir1",
6874 " v subdir1",
6875 " v nested1",
6876 " file1.txt",
6877 " file2.txt",
6878 " v subdir2",
6879 " file3.txt",
6880 " v dir2 <== selected",
6881 " file4.txt",
6882 ],
6883 "Initial state with directories expanded"
6884 );
6885
6886 select_path(&panel, "root/dir1", cx);
6887 cx.run_until_parked();
6888
6889 panel.update_in(cx, |panel, window, cx| {
6890 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6891 });
6892 cx.run_until_parked();
6893
6894 assert_eq!(
6895 visible_entries_as_strings(&panel, 0..20, cx),
6896 &[
6897 "v root",
6898 " > dir1 <== selected",
6899 " v dir2",
6900 " file4.txt",
6901 ],
6902 "dir1 and all its children should be collapsed, dir2 should remain expanded"
6903 );
6904
6905 toggle_expand_dir(&panel, "root/dir1", cx);
6906 cx.run_until_parked();
6907
6908 assert_eq!(
6909 visible_entries_as_strings(&panel, 0..20, cx),
6910 &[
6911 "v root",
6912 " v dir1 <== selected",
6913 " > subdir1",
6914 " > subdir2",
6915 " v dir2",
6916 " file4.txt",
6917 ],
6918 "After re-expanding dir1, its children should still be collapsed"
6919 );
6920}
6921
6922#[gpui::test]
6923async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
6924 init_test(cx);
6925
6926 let fs = FakeFs::new(cx.executor());
6927 fs.insert_tree(
6928 path!("/root"),
6929 json!({
6930 "dir1": {
6931 "subdir1": {
6932 "file1.txt": ""
6933 },
6934 "file2.txt": ""
6935 },
6936 "dir2": {
6937 "file3.txt": ""
6938 }
6939 }),
6940 )
6941 .await;
6942
6943 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6944 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6945 let workspace = window
6946 .read_with(cx, |mw, _| mw.workspace().clone())
6947 .unwrap();
6948 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6949
6950 let panel = workspace.update_in(cx, ProjectPanel::new);
6951 cx.run_until_parked();
6952
6953 toggle_expand_dir(&panel, "root/dir1", cx);
6954 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6955 toggle_expand_dir(&panel, "root/dir2", cx);
6956
6957 assert_eq!(
6958 visible_entries_as_strings(&panel, 0..20, cx),
6959 &[
6960 "v root",
6961 " v dir1",
6962 " v subdir1",
6963 " file1.txt",
6964 " file2.txt",
6965 " v dir2 <== selected",
6966 " file3.txt",
6967 ],
6968 "Initial state with directories expanded"
6969 );
6970
6971 // Select the root and collapse it and its children
6972 select_path(&panel, "root", cx);
6973 cx.run_until_parked();
6974
6975 panel.update_in(cx, |panel, window, cx| {
6976 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
6977 });
6978 cx.run_until_parked();
6979
6980 // The root and all its children should be collapsed
6981 assert_eq!(
6982 visible_entries_as_strings(&panel, 0..20, cx),
6983 &["> root <== selected"],
6984 "Root and all children should be collapsed"
6985 );
6986
6987 // Re-expand root and dir1, verify children were recursively collapsed
6988 toggle_expand_dir(&panel, "root", cx);
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 " file2.txt",
6999 " > dir2",
7000 ],
7001 "After re-expanding root and dir1, subdir1 should still be collapsed"
7002 );
7003}
7004
7005#[gpui::test]
7006async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7007 init_test(cx);
7008
7009 let fs = FakeFs::new(cx.executor());
7010 fs.insert_tree(
7011 path!("/root1"),
7012 json!({
7013 "dir1": {
7014 "subdir1": {
7015 "file1.txt": ""
7016 },
7017 "file2.txt": ""
7018 }
7019 }),
7020 )
7021 .await;
7022 fs.insert_tree(
7023 path!("/root2"),
7024 json!({
7025 "dir2": {
7026 "file3.txt": ""
7027 },
7028 "file4.txt": ""
7029 }),
7030 )
7031 .await;
7032
7033 let project = Project::test(
7034 fs.clone(),
7035 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7036 cx,
7037 )
7038 .await;
7039 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7040 let workspace = window
7041 .read_with(cx, |mw, _| mw.workspace().clone())
7042 .unwrap();
7043 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7044
7045 let panel = workspace.update_in(cx, ProjectPanel::new);
7046 cx.run_until_parked();
7047
7048 toggle_expand_dir(&panel, "root1/dir1", cx);
7049 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7050 toggle_expand_dir(&panel, "root2/dir2", cx);
7051
7052 assert_eq!(
7053 visible_entries_as_strings(&panel, 0..20, cx),
7054 &[
7055 "v root1",
7056 " v dir1",
7057 " v subdir1",
7058 " file1.txt",
7059 " file2.txt",
7060 "v root2",
7061 " v dir2 <== selected",
7062 " file3.txt",
7063 " file4.txt",
7064 ],
7065 "Initial state with directories expanded across worktrees"
7066 );
7067
7068 // Select root1 and collapse it and its children.
7069 // In a multi-worktree project, this should only collapse the selected worktree,
7070 // leaving other worktrees unaffected.
7071 select_path(&panel, "root1", cx);
7072 cx.run_until_parked();
7073
7074 panel.update_in(cx, |panel, window, cx| {
7075 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7076 });
7077 cx.run_until_parked();
7078
7079 assert_eq!(
7080 visible_entries_as_strings(&panel, 0..20, cx),
7081 &[
7082 "> root1 <== selected",
7083 "v root2",
7084 " v dir2",
7085 " file3.txt",
7086 " file4.txt",
7087 ],
7088 "Only root1 should be collapsed, root2 should remain expanded"
7089 );
7090
7091 // Re-expand root1 and verify its children were recursively collapsed
7092 toggle_expand_dir(&panel, "root1", cx);
7093
7094 assert_eq!(
7095 visible_entries_as_strings(&panel, 0..20, cx),
7096 &[
7097 "v root1 <== selected",
7098 " > dir1",
7099 "v root2",
7100 " v dir2",
7101 " file3.txt",
7102 " file4.txt",
7103 ],
7104 "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
7105 );
7106}
7107
7108#[gpui::test]
7109async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7110 init_test(cx);
7111
7112 let fs = FakeFs::new(cx.executor());
7113 fs.insert_tree(
7114 path!("/root1"),
7115 json!({
7116 "dir1": {
7117 "subdir1": {
7118 "file1.txt": ""
7119 },
7120 "file2.txt": ""
7121 }
7122 }),
7123 )
7124 .await;
7125 fs.insert_tree(
7126 path!("/root2"),
7127 json!({
7128 "dir2": {
7129 "subdir2": {
7130 "file3.txt": ""
7131 },
7132 "file4.txt": ""
7133 }
7134 }),
7135 )
7136 .await;
7137
7138 let project = Project::test(
7139 fs.clone(),
7140 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7141 cx,
7142 )
7143 .await;
7144 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7145 let workspace = window
7146 .read_with(cx, |mw, _| mw.workspace().clone())
7147 .unwrap();
7148 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7149
7150 let panel = workspace.update_in(cx, ProjectPanel::new);
7151 cx.run_until_parked();
7152
7153 toggle_expand_dir(&panel, "root1/dir1", cx);
7154 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7155 toggle_expand_dir(&panel, "root2/dir2", cx);
7156 toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
7157
7158 assert_eq!(
7159 visible_entries_as_strings(&panel, 0..20, cx),
7160 &[
7161 "v root1",
7162 " v dir1",
7163 " v subdir1",
7164 " file1.txt",
7165 " file2.txt",
7166 "v root2",
7167 " v dir2",
7168 " v subdir2 <== selected",
7169 " file3.txt",
7170 " file4.txt",
7171 ],
7172 "Initial state with directories expanded across worktrees"
7173 );
7174
7175 // Select dir1 in root1 and collapse it
7176 select_path(&panel, "root1/dir1", cx);
7177 cx.run_until_parked();
7178
7179 panel.update_in(cx, |panel, window, cx| {
7180 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7181 });
7182 cx.run_until_parked();
7183
7184 assert_eq!(
7185 visible_entries_as_strings(&panel, 0..20, cx),
7186 &[
7187 "v root1",
7188 " > dir1 <== selected",
7189 "v root2",
7190 " v dir2",
7191 " v subdir2",
7192 " file3.txt",
7193 " file4.txt",
7194 ],
7195 "Only dir1 should be collapsed, root2 should be completely unaffected"
7196 );
7197
7198 // Re-expand dir1 and verify subdir1 was recursively collapsed
7199 toggle_expand_dir(&panel, "root1/dir1", cx);
7200
7201 assert_eq!(
7202 visible_entries_as_strings(&panel, 0..20, cx),
7203 &[
7204 "v root1",
7205 " v dir1 <== selected",
7206 " > subdir1",
7207 " file2.txt",
7208 "v root2",
7209 " v dir2",
7210 " v subdir2",
7211 " file3.txt",
7212 " file4.txt",
7213 ],
7214 "After re-expanding dir1, subdir1 should still be collapsed"
7215 );
7216}
7217
7218#[gpui::test]
7219async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
7220 init_test(cx);
7221
7222 let fs = FakeFs::new(cx.executor());
7223 fs.insert_tree(
7224 path!("/root"),
7225 json!({
7226 "dir1": {
7227 "subdir1": {
7228 "file1.txt": ""
7229 },
7230 "file2.txt": ""
7231 },
7232 "dir2": {
7233 "file3.txt": ""
7234 }
7235 }),
7236 )
7237 .await;
7238
7239 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7240 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7241 let workspace = window
7242 .read_with(cx, |mw, _| mw.workspace().clone())
7243 .unwrap();
7244 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7245
7246 let panel = workspace.update_in(cx, ProjectPanel::new);
7247 cx.run_until_parked();
7248
7249 toggle_expand_dir(&panel, "root/dir1", cx);
7250 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7251 toggle_expand_dir(&panel, "root/dir2", cx);
7252
7253 assert_eq!(
7254 visible_entries_as_strings(&panel, 0..20, cx),
7255 &[
7256 "v root",
7257 " v dir1",
7258 " v subdir1",
7259 " file1.txt",
7260 " file2.txt",
7261 " v dir2 <== selected",
7262 " file3.txt",
7263 ],
7264 "Initial state with directories expanded"
7265 );
7266
7267 select_path(&panel, "root", cx);
7268 cx.run_until_parked();
7269
7270 panel.update_in(cx, |panel, window, cx| {
7271 panel.collapse_all_for_root(window, cx);
7272 });
7273 cx.run_until_parked();
7274
7275 assert_eq!(
7276 visible_entries_as_strings(&panel, 0..20, cx),
7277 &["v root <== selected", " > dir1", " > dir2"],
7278 "Root should remain expanded but all children should be collapsed"
7279 );
7280
7281 toggle_expand_dir(&panel, "root/dir1", cx);
7282 cx.run_until_parked();
7283
7284 assert_eq!(
7285 visible_entries_as_strings(&panel, 0..20, cx),
7286 &[
7287 "v root",
7288 " v dir1 <== selected",
7289 " > subdir1",
7290 " file2.txt",
7291 " > dir2",
7292 ],
7293 "After re-expanding dir1, subdir1 should still be collapsed"
7294 );
7295}
7296
7297#[gpui::test]
7298async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7299 init_test(cx);
7300
7301 let fs = FakeFs::new(cx.executor());
7302 fs.insert_tree(
7303 path!("/root1"),
7304 json!({
7305 "dir1": {
7306 "subdir1": {
7307 "file1.txt": ""
7308 },
7309 "file2.txt": ""
7310 }
7311 }),
7312 )
7313 .await;
7314 fs.insert_tree(
7315 path!("/root2"),
7316 json!({
7317 "dir2": {
7318 "file3.txt": ""
7319 },
7320 "file4.txt": ""
7321 }),
7322 )
7323 .await;
7324
7325 let project = Project::test(
7326 fs.clone(),
7327 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7328 cx,
7329 )
7330 .await;
7331 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7332 let workspace = window
7333 .read_with(cx, |mw, _| mw.workspace().clone())
7334 .unwrap();
7335 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7336
7337 let panel = workspace.update_in(cx, ProjectPanel::new);
7338 cx.run_until_parked();
7339
7340 toggle_expand_dir(&panel, "root1/dir1", cx);
7341 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7342 toggle_expand_dir(&panel, "root2/dir2", cx);
7343
7344 assert_eq!(
7345 visible_entries_as_strings(&panel, 0..20, cx),
7346 &[
7347 "v root1",
7348 " v dir1",
7349 " v subdir1",
7350 " file1.txt",
7351 " file2.txt",
7352 "v root2",
7353 " v dir2 <== selected",
7354 " file3.txt",
7355 " file4.txt",
7356 ],
7357 "Initial state with directories expanded across worktrees"
7358 );
7359
7360 select_path(&panel, "root1", cx);
7361 cx.run_until_parked();
7362
7363 panel.update_in(cx, |panel, window, cx| {
7364 panel.collapse_all_for_root(window, cx);
7365 });
7366 cx.run_until_parked();
7367
7368 assert_eq!(
7369 visible_entries_as_strings(&panel, 0..20, cx),
7370 &[
7371 "> root1 <== selected",
7372 "v root2",
7373 " v dir2",
7374 " file3.txt",
7375 " file4.txt",
7376 ],
7377 "With multiple worktrees, root1 should collapse completely (including itself)"
7378 );
7379}
7380
7381#[gpui::test]
7382async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
7383 init_test(cx);
7384
7385 let fs = FakeFs::new(cx.executor());
7386 fs.insert_tree(
7387 path!("/root"),
7388 json!({
7389 "dir1": {
7390 "subdir1": {
7391 "file1.txt": ""
7392 },
7393 },
7394 "dir2": {
7395 "file2.txt": ""
7396 }
7397 }),
7398 )
7399 .await;
7400
7401 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7402 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7403 let workspace = window
7404 .read_with(cx, |mw, _| mw.workspace().clone())
7405 .unwrap();
7406 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7407
7408 let panel = workspace.update_in(cx, ProjectPanel::new);
7409 cx.run_until_parked();
7410
7411 toggle_expand_dir(&panel, "root/dir1", cx);
7412 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7413 toggle_expand_dir(&panel, "root/dir2", cx);
7414
7415 assert_eq!(
7416 visible_entries_as_strings(&panel, 0..20, cx),
7417 &[
7418 "v root",
7419 " v dir1",
7420 " v subdir1",
7421 " file1.txt",
7422 " v dir2 <== selected",
7423 " file2.txt",
7424 ],
7425 "Initial state with directories expanded"
7426 );
7427
7428 select_path(&panel, "root/dir1", cx);
7429 cx.run_until_parked();
7430
7431 panel.update_in(cx, |panel, window, cx| {
7432 panel.collapse_all_for_root(window, cx);
7433 });
7434 cx.run_until_parked();
7435
7436 assert_eq!(
7437 visible_entries_as_strings(&panel, 0..20, cx),
7438 &[
7439 "v root",
7440 " v dir1 <== selected",
7441 " v subdir1",
7442 " file1.txt",
7443 " v dir2",
7444 " file2.txt",
7445 ],
7446 "collapse_all_for_root should be a no-op when called on a non-root directory"
7447 );
7448}
7449
7450#[gpui::test]
7451async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
7452 init_test(cx);
7453
7454 let fs = FakeFs::new(cx.executor());
7455 fs.insert_tree(
7456 path!("/root"),
7457 json!({
7458 "dir1": {
7459 "file1.txt": "",
7460 },
7461 }),
7462 )
7463 .await;
7464
7465 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7466 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7467 let workspace = window
7468 .read_with(cx, |mw, _| mw.workspace().clone())
7469 .unwrap();
7470 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7471
7472 let panel = workspace.update_in(cx, |workspace, window, cx| {
7473 let panel = ProjectPanel::new(workspace, window, cx);
7474 workspace.add_panel(panel.clone(), window, cx);
7475 panel
7476 });
7477 cx.run_until_parked();
7478
7479 #[rustfmt::skip]
7480 assert_eq!(
7481 visible_entries_as_strings(&panel, 0..20, cx),
7482 &[
7483 "v root",
7484 " > dir1",
7485 ],
7486 "Initial state with nothing selected"
7487 );
7488
7489 panel.update_in(cx, |panel, window, cx| {
7490 panel.new_file(&NewFile, window, cx);
7491 });
7492 cx.run_until_parked();
7493 panel.update_in(cx, |panel, window, cx| {
7494 assert!(panel.filename_editor.read(cx).is_focused(window));
7495 });
7496 panel
7497 .update_in(cx, |panel, window, cx| {
7498 panel.filename_editor.update(cx, |editor, cx| {
7499 editor.set_text("hello_from_no_selections", window, cx)
7500 });
7501 panel.confirm_edit(true, window, cx).unwrap()
7502 })
7503 .await
7504 .unwrap();
7505 cx.run_until_parked();
7506 #[rustfmt::skip]
7507 assert_eq!(
7508 visible_entries_as_strings(&panel, 0..20, cx),
7509 &[
7510 "v root",
7511 " > dir1",
7512 " hello_from_no_selections <== selected <== marked",
7513 ],
7514 "A new file is created under the root directory"
7515 );
7516}
7517
7518#[gpui::test]
7519async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
7520 init_test(cx);
7521
7522 let fs = FakeFs::new(cx.executor());
7523 fs.insert_tree(
7524 path!("/root"),
7525 json!({
7526 "existing_dir": {
7527 "existing_file.txt": "",
7528 },
7529 "existing_file.txt": "",
7530 }),
7531 )
7532 .await;
7533
7534 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7535 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7536 let workspace = window
7537 .read_with(cx, |mw, _| mw.workspace().clone())
7538 .unwrap();
7539 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7540
7541 cx.update(|_, cx| {
7542 let settings = *ProjectPanelSettings::get_global(cx);
7543 ProjectPanelSettings::override_global(
7544 ProjectPanelSettings {
7545 hide_root: true,
7546 ..settings
7547 },
7548 cx,
7549 );
7550 });
7551
7552 let panel = workspace.update_in(cx, |workspace, window, cx| {
7553 let panel = ProjectPanel::new(workspace, window, cx);
7554 workspace.add_panel(panel.clone(), window, cx);
7555 panel
7556 });
7557 cx.run_until_parked();
7558
7559 #[rustfmt::skip]
7560 assert_eq!(
7561 visible_entries_as_strings(&panel, 0..20, cx),
7562 &[
7563 "> existing_dir",
7564 " existing_file.txt",
7565 ],
7566 "Initial state with hide_root=true, root should be hidden and nothing selected"
7567 );
7568
7569 panel.update(cx, |panel, _| {
7570 assert!(
7571 panel.selection.is_none(),
7572 "Should have no selection initially"
7573 );
7574 });
7575
7576 // Test 1: Create new file when no entry is selected
7577 panel.update_in(cx, |panel, window, cx| {
7578 panel.new_file(&NewFile, window, cx);
7579 });
7580 cx.run_until_parked();
7581 panel.update_in(cx, |panel, window, cx| {
7582 assert!(panel.filename_editor.read(cx).is_focused(window));
7583 });
7584 cx.run_until_parked();
7585 #[rustfmt::skip]
7586 assert_eq!(
7587 visible_entries_as_strings(&panel, 0..20, cx),
7588 &[
7589 "> existing_dir",
7590 " [EDITOR: ''] <== selected",
7591 " existing_file.txt",
7592 ],
7593 "Editor should appear at root level when hide_root=true and no selection"
7594 );
7595
7596 let confirm = panel.update_in(cx, |panel, window, cx| {
7597 panel.filename_editor.update(cx, |editor, cx| {
7598 editor.set_text("new_file_at_root.txt", window, cx)
7599 });
7600 panel.confirm_edit(true, window, cx).unwrap()
7601 });
7602 confirm.await.unwrap();
7603 cx.run_until_parked();
7604
7605 #[rustfmt::skip]
7606 assert_eq!(
7607 visible_entries_as_strings(&panel, 0..20, cx),
7608 &[
7609 "> existing_dir",
7610 " existing_file.txt",
7611 " new_file_at_root.txt <== selected <== marked",
7612 ],
7613 "New file should be created at root level and visible without root prefix"
7614 );
7615
7616 assert!(
7617 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
7618 "File should be created in the actual root directory"
7619 );
7620
7621 // Test 2: Create new directory when no entry is selected
7622 panel.update(cx, |panel, _| {
7623 panel.selection = None;
7624 });
7625
7626 panel.update_in(cx, |panel, window, cx| {
7627 panel.new_directory(&NewDirectory, window, cx);
7628 });
7629 cx.run_until_parked();
7630
7631 panel.update_in(cx, |panel, window, cx| {
7632 assert!(panel.filename_editor.read(cx).is_focused(window));
7633 });
7634
7635 #[rustfmt::skip]
7636 assert_eq!(
7637 visible_entries_as_strings(&panel, 0..20, cx),
7638 &[
7639 "> [EDITOR: ''] <== selected",
7640 "> existing_dir",
7641 " existing_file.txt",
7642 " new_file_at_root.txt",
7643 ],
7644 "Directory editor should appear at root level when hide_root=true and no selection"
7645 );
7646
7647 let confirm = panel.update_in(cx, |panel, window, cx| {
7648 panel.filename_editor.update(cx, |editor, cx| {
7649 editor.set_text("new_dir_at_root", window, cx)
7650 });
7651 panel.confirm_edit(true, window, cx).unwrap()
7652 });
7653 confirm.await.unwrap();
7654 cx.run_until_parked();
7655
7656 #[rustfmt::skip]
7657 assert_eq!(
7658 visible_entries_as_strings(&panel, 0..20, cx),
7659 &[
7660 "> existing_dir",
7661 "v new_dir_at_root <== selected",
7662 " existing_file.txt",
7663 " new_file_at_root.txt",
7664 ],
7665 "New directory should be created at root level and visible without root prefix"
7666 );
7667
7668 assert!(
7669 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
7670 "Directory should be created in the actual root directory"
7671 );
7672}
7673
7674#[cfg(windows)]
7675#[gpui::test]
7676async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
7677 init_test(cx);
7678
7679 let fs = FakeFs::new(cx.executor());
7680 fs.insert_tree(
7681 path!("/root"),
7682 json!({
7683 "dir1": {
7684 "file1.txt": "",
7685 },
7686 }),
7687 )
7688 .await;
7689
7690 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7691 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7692 let workspace = window
7693 .read_with(cx, |mw, _| mw.workspace().clone())
7694 .unwrap();
7695 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7696
7697 let panel = workspace.update_in(cx, |workspace, window, cx| {
7698 let panel = ProjectPanel::new(workspace, window, cx);
7699 workspace.add_panel(panel.clone(), window, cx);
7700 panel
7701 });
7702 cx.run_until_parked();
7703
7704 #[rustfmt::skip]
7705 assert_eq!(
7706 visible_entries_as_strings(&panel, 0..20, cx),
7707 &[
7708 "v root",
7709 " > dir1",
7710 ],
7711 "Initial state with nothing selected"
7712 );
7713
7714 panel.update_in(cx, |panel, window, cx| {
7715 panel.new_file(&NewFile, window, cx);
7716 });
7717 cx.run_until_parked();
7718 panel.update_in(cx, |panel, window, cx| {
7719 assert!(panel.filename_editor.read(cx).is_focused(window));
7720 });
7721 panel
7722 .update_in(cx, |panel, window, cx| {
7723 panel
7724 .filename_editor
7725 .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
7726 panel.confirm_edit(true, window, cx).unwrap()
7727 })
7728 .await
7729 .unwrap();
7730 cx.run_until_parked();
7731 #[rustfmt::skip]
7732 assert_eq!(
7733 visible_entries_as_strings(&panel, 0..20, cx),
7734 &[
7735 "v root",
7736 " > dir1",
7737 " foo <== selected <== marked",
7738 ],
7739 "A new file is created under the root directory without the trailing dot"
7740 );
7741}
7742
7743#[gpui::test]
7744async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
7745 init_test(cx);
7746
7747 let fs = FakeFs::new(cx.executor());
7748 fs.insert_tree(
7749 "/root",
7750 json!({
7751 "dir1": {
7752 "file1.txt": "",
7753 "dir2": {
7754 "file2.txt": ""
7755 }
7756 },
7757 "file3.txt": ""
7758 }),
7759 )
7760 .await;
7761
7762 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7763 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7764 let workspace = window
7765 .read_with(cx, |mw, _| mw.workspace().clone())
7766 .unwrap();
7767 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7768 let panel = workspace.update_in(cx, ProjectPanel::new);
7769 cx.run_until_parked();
7770
7771 panel.update(cx, |panel, cx| {
7772 let project = panel.project.read(cx);
7773 let worktree = project.visible_worktrees(cx).next().unwrap();
7774 let worktree = worktree.read(cx);
7775
7776 // Test 1: Target is a directory, should highlight the directory itself
7777 let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
7778 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
7779 assert_eq!(
7780 result,
7781 Some(dir_entry.id),
7782 "Should highlight directory itself"
7783 );
7784
7785 // Test 2: Target is nested file, should highlight immediate parent
7786 let nested_file = worktree
7787 .entry_for_path(rel_path("dir1/dir2/file2.txt"))
7788 .unwrap();
7789 let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
7790 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
7791 assert_eq!(
7792 result,
7793 Some(nested_parent.id),
7794 "Should highlight immediate parent"
7795 );
7796
7797 // Test 3: Target is root level file, should highlight root
7798 let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
7799 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
7800 assert_eq!(
7801 result,
7802 Some(worktree.root_entry().unwrap().id),
7803 "Root level file should return None"
7804 );
7805
7806 // Test 4: Target is root itself, should highlight root
7807 let root_entry = worktree.root_entry().unwrap();
7808 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
7809 assert_eq!(
7810 result,
7811 Some(root_entry.id),
7812 "Root level file should return None"
7813 );
7814 });
7815}
7816
7817#[gpui::test]
7818async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
7819 init_test(cx);
7820
7821 let fs = FakeFs::new(cx.executor());
7822 fs.insert_tree(
7823 "/root",
7824 json!({
7825 "parent_dir": {
7826 "child_file.txt": "",
7827 "sibling_file.txt": "",
7828 "child_dir": {
7829 "nested_file.txt": ""
7830 }
7831 },
7832 "other_dir": {
7833 "other_file.txt": ""
7834 }
7835 }),
7836 )
7837 .await;
7838
7839 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7840 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7841 let workspace = window
7842 .read_with(cx, |mw, _| mw.workspace().clone())
7843 .unwrap();
7844 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7845 let panel = workspace.update_in(cx, ProjectPanel::new);
7846 cx.run_until_parked();
7847
7848 panel.update(cx, |panel, cx| {
7849 let project = panel.project.read(cx);
7850 let worktree = project.visible_worktrees(cx).next().unwrap();
7851 let worktree_id = worktree.read(cx).id();
7852 let worktree = worktree.read(cx);
7853
7854 let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
7855 let child_file = worktree
7856 .entry_for_path(rel_path("parent_dir/child_file.txt"))
7857 .unwrap();
7858 let sibling_file = worktree
7859 .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
7860 .unwrap();
7861 let child_dir = worktree
7862 .entry_for_path(rel_path("parent_dir/child_dir"))
7863 .unwrap();
7864 let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
7865 let other_file = worktree
7866 .entry_for_path(rel_path("other_dir/other_file.txt"))
7867 .unwrap();
7868
7869 // Test 1: Single item drag, don't highlight parent directory
7870 let dragged_selection = DraggedSelection {
7871 active_selection: SelectedEntry {
7872 worktree_id,
7873 entry_id: child_file.id,
7874 },
7875 marked_selections: Arc::new([SelectedEntry {
7876 worktree_id,
7877 entry_id: child_file.id,
7878 }]),
7879 };
7880 let result =
7881 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7882 assert_eq!(result, None, "Should not highlight parent of dragged item");
7883
7884 // Test 2: Single item drag, don't highlight sibling files
7885 let result = panel.highlight_entry_for_selection_drag(
7886 sibling_file,
7887 worktree,
7888 &dragged_selection,
7889 cx,
7890 );
7891 assert_eq!(result, None, "Should not highlight sibling files");
7892
7893 // Test 3: Single item drag, highlight unrelated directory
7894 let result =
7895 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
7896 assert_eq!(
7897 result,
7898 Some(other_dir.id),
7899 "Should highlight unrelated directory"
7900 );
7901
7902 // Test 4: Single item drag, highlight sibling directory
7903 let result =
7904 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7905 assert_eq!(
7906 result,
7907 Some(child_dir.id),
7908 "Should highlight sibling directory"
7909 );
7910
7911 // Test 5: Multiple items drag, highlight parent directory
7912 let dragged_selection = DraggedSelection {
7913 active_selection: SelectedEntry {
7914 worktree_id,
7915 entry_id: child_file.id,
7916 },
7917 marked_selections: Arc::new([
7918 SelectedEntry {
7919 worktree_id,
7920 entry_id: child_file.id,
7921 },
7922 SelectedEntry {
7923 worktree_id,
7924 entry_id: sibling_file.id,
7925 },
7926 ]),
7927 };
7928 let result =
7929 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
7930 assert_eq!(
7931 result,
7932 Some(parent_dir.id),
7933 "Should highlight parent with multiple items"
7934 );
7935
7936 // Test 6: Target is file in different directory, highlight parent
7937 let result =
7938 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
7939 assert_eq!(
7940 result,
7941 Some(other_dir.id),
7942 "Should highlight parent of target file"
7943 );
7944
7945 // Test 7: Target is directory, always highlight
7946 let result =
7947 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
7948 assert_eq!(
7949 result,
7950 Some(child_dir.id),
7951 "Should always highlight directories"
7952 );
7953 });
7954}
7955
7956#[gpui::test]
7957async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
7958 init_test(cx);
7959
7960 let fs = FakeFs::new(cx.executor());
7961 fs.insert_tree(
7962 "/root1",
7963 json!({
7964 "src": {
7965 "main.rs": "",
7966 "lib.rs": ""
7967 }
7968 }),
7969 )
7970 .await;
7971 fs.insert_tree(
7972 "/root2",
7973 json!({
7974 "src": {
7975 "main.rs": "",
7976 "test.rs": ""
7977 }
7978 }),
7979 )
7980 .await;
7981
7982 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7983 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7984 let workspace = window
7985 .read_with(cx, |mw, _| mw.workspace().clone())
7986 .unwrap();
7987 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7988 let panel = workspace.update_in(cx, ProjectPanel::new);
7989 cx.run_until_parked();
7990
7991 panel.update(cx, |panel, cx| {
7992 let project = panel.project.read(cx);
7993 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
7994
7995 let worktree_a = &worktrees[0];
7996 let main_rs_from_a = worktree_a
7997 .read(cx)
7998 .entry_for_path(rel_path("src/main.rs"))
7999 .unwrap();
8000
8001 let worktree_b = &worktrees[1];
8002 let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
8003 let main_rs_from_b = worktree_b
8004 .read(cx)
8005 .entry_for_path(rel_path("src/main.rs"))
8006 .unwrap();
8007
8008 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
8009 let dragged_selection = DraggedSelection {
8010 active_selection: SelectedEntry {
8011 worktree_id: worktree_a.read(cx).id(),
8012 entry_id: main_rs_from_a.id,
8013 },
8014 marked_selections: Arc::new([SelectedEntry {
8015 worktree_id: worktree_a.read(cx).id(),
8016 entry_id: main_rs_from_a.id,
8017 }]),
8018 };
8019
8020 let result = panel.highlight_entry_for_selection_drag(
8021 src_dir_from_b,
8022 worktree_b.read(cx),
8023 &dragged_selection,
8024 cx,
8025 );
8026 assert_eq!(
8027 result,
8028 Some(src_dir_from_b.id),
8029 "Should highlight target directory from different worktree even with same relative path"
8030 );
8031
8032 // Test dragging file from worktree A onto file with same relative path in worktree B
8033 let result = panel.highlight_entry_for_selection_drag(
8034 main_rs_from_b,
8035 worktree_b.read(cx),
8036 &dragged_selection,
8037 cx,
8038 );
8039 assert_eq!(
8040 result,
8041 Some(src_dir_from_b.id),
8042 "Should highlight parent of target file from different worktree"
8043 );
8044 });
8045}
8046
8047#[gpui::test]
8048async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
8049 init_test(cx);
8050
8051 let fs = FakeFs::new(cx.executor());
8052 fs.insert_tree(
8053 "/root1",
8054 json!({
8055 "parent_dir": {
8056 "child_file.txt": "",
8057 "nested_dir": {
8058 "nested_file.txt": ""
8059 }
8060 },
8061 "root_file.txt": ""
8062 }),
8063 )
8064 .await;
8065
8066 fs.insert_tree(
8067 "/root2",
8068 json!({
8069 "other_dir": {
8070 "other_file.txt": ""
8071 }
8072 }),
8073 )
8074 .await;
8075
8076 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8077 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8078 let workspace = window
8079 .read_with(cx, |mw, _| mw.workspace().clone())
8080 .unwrap();
8081 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8082 let panel = workspace.update_in(cx, ProjectPanel::new);
8083 cx.run_until_parked();
8084
8085 panel.update(cx, |panel, cx| {
8086 let project = panel.project.read(cx);
8087 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8088 let worktree1 = worktrees[0].read(cx);
8089 let worktree2 = worktrees[1].read(cx);
8090 let worktree1_id = worktree1.id();
8091 let _worktree2_id = worktree2.id();
8092
8093 let root1_entry = worktree1.root_entry().unwrap();
8094 let root2_entry = worktree2.root_entry().unwrap();
8095 let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
8096 let child_file = worktree1
8097 .entry_for_path(rel_path("parent_dir/child_file.txt"))
8098 .unwrap();
8099 let nested_file = worktree1
8100 .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
8101 .unwrap();
8102 let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
8103
8104 // Test 1: Multiple entries - should always highlight background
8105 let multiple_dragged_selection = DraggedSelection {
8106 active_selection: SelectedEntry {
8107 worktree_id: worktree1_id,
8108 entry_id: child_file.id,
8109 },
8110 marked_selections: Arc::new([
8111 SelectedEntry {
8112 worktree_id: worktree1_id,
8113 entry_id: child_file.id,
8114 },
8115 SelectedEntry {
8116 worktree_id: worktree1_id,
8117 entry_id: nested_file.id,
8118 },
8119 ]),
8120 };
8121
8122 let result = panel.should_highlight_background_for_selection_drag(
8123 &multiple_dragged_selection,
8124 root1_entry.id,
8125 cx,
8126 );
8127 assert!(result, "Should highlight background for multiple entries");
8128
8129 // Test 2: Single entry with non-empty parent path - should highlight background
8130 let nested_dragged_selection = DraggedSelection {
8131 active_selection: SelectedEntry {
8132 worktree_id: worktree1_id,
8133 entry_id: nested_file.id,
8134 },
8135 marked_selections: Arc::new([SelectedEntry {
8136 worktree_id: worktree1_id,
8137 entry_id: nested_file.id,
8138 }]),
8139 };
8140
8141 let result = panel.should_highlight_background_for_selection_drag(
8142 &nested_dragged_selection,
8143 root1_entry.id,
8144 cx,
8145 );
8146 assert!(result, "Should highlight background for nested file");
8147
8148 // Test 3: Single entry at root level, same worktree - should NOT highlight background
8149 let root_file_dragged_selection = DraggedSelection {
8150 active_selection: SelectedEntry {
8151 worktree_id: worktree1_id,
8152 entry_id: root_file.id,
8153 },
8154 marked_selections: Arc::new([SelectedEntry {
8155 worktree_id: worktree1_id,
8156 entry_id: root_file.id,
8157 }]),
8158 };
8159
8160 let result = panel.should_highlight_background_for_selection_drag(
8161 &root_file_dragged_selection,
8162 root1_entry.id,
8163 cx,
8164 );
8165 assert!(
8166 !result,
8167 "Should NOT highlight background for root file in same worktree"
8168 );
8169
8170 // Test 4: Single entry at root level, different worktree - should highlight background
8171 let result = panel.should_highlight_background_for_selection_drag(
8172 &root_file_dragged_selection,
8173 root2_entry.id,
8174 cx,
8175 );
8176 assert!(
8177 result,
8178 "Should highlight background for root file from different worktree"
8179 );
8180
8181 // Test 5: Single entry in subdirectory - should highlight background
8182 let child_file_dragged_selection = DraggedSelection {
8183 active_selection: SelectedEntry {
8184 worktree_id: worktree1_id,
8185 entry_id: child_file.id,
8186 },
8187 marked_selections: Arc::new([SelectedEntry {
8188 worktree_id: worktree1_id,
8189 entry_id: child_file.id,
8190 }]),
8191 };
8192
8193 let result = panel.should_highlight_background_for_selection_drag(
8194 &child_file_dragged_selection,
8195 root1_entry.id,
8196 cx,
8197 );
8198 assert!(
8199 result,
8200 "Should highlight background for file with non-empty parent path"
8201 );
8202 });
8203}
8204
8205#[gpui::test]
8206async fn test_hide_root(cx: &mut gpui::TestAppContext) {
8207 init_test(cx);
8208
8209 let fs = FakeFs::new(cx.executor());
8210 fs.insert_tree(
8211 "/root1",
8212 json!({
8213 "dir1": {
8214 "file1.txt": "content",
8215 "file2.txt": "content",
8216 },
8217 "dir2": {
8218 "file3.txt": "content",
8219 },
8220 "file4.txt": "content",
8221 }),
8222 )
8223 .await;
8224
8225 fs.insert_tree(
8226 "/root2",
8227 json!({
8228 "dir3": {
8229 "file5.txt": "content",
8230 },
8231 "file6.txt": "content",
8232 }),
8233 )
8234 .await;
8235
8236 // Test 1: Single worktree with hide_root = false
8237 {
8238 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8239 let window =
8240 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8241 let workspace = window
8242 .read_with(cx, |mw, _| mw.workspace().clone())
8243 .unwrap();
8244 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8245
8246 cx.update(|_, cx| {
8247 let settings = *ProjectPanelSettings::get_global(cx);
8248 ProjectPanelSettings::override_global(
8249 ProjectPanelSettings {
8250 hide_root: false,
8251 ..settings
8252 },
8253 cx,
8254 );
8255 });
8256
8257 let panel = workspace.update_in(cx, ProjectPanel::new);
8258 cx.run_until_parked();
8259
8260 #[rustfmt::skip]
8261 assert_eq!(
8262 visible_entries_as_strings(&panel, 0..10, cx),
8263 &[
8264 "v root1",
8265 " > dir1",
8266 " > dir2",
8267 " file4.txt",
8268 ],
8269 "With hide_root=false and single worktree, root should be visible"
8270 );
8271 }
8272
8273 // Test 2: Single worktree with hide_root = true
8274 {
8275 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8276 let window =
8277 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8278 let workspace = window
8279 .read_with(cx, |mw, _| mw.workspace().clone())
8280 .unwrap();
8281 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8282
8283 // Set hide_root to true
8284 cx.update(|_, cx| {
8285 let settings = *ProjectPanelSettings::get_global(cx);
8286 ProjectPanelSettings::override_global(
8287 ProjectPanelSettings {
8288 hide_root: true,
8289 ..settings
8290 },
8291 cx,
8292 );
8293 });
8294
8295 let panel = workspace.update_in(cx, ProjectPanel::new);
8296 cx.run_until_parked();
8297
8298 assert_eq!(
8299 visible_entries_as_strings(&panel, 0..10, cx),
8300 &["> dir1", "> dir2", " file4.txt",],
8301 "With hide_root=true and single worktree, root should be hidden"
8302 );
8303
8304 // Test expanding directories still works without root
8305 toggle_expand_dir(&panel, "root1/dir1", cx);
8306 assert_eq!(
8307 visible_entries_as_strings(&panel, 0..10, cx),
8308 &[
8309 "v dir1 <== selected",
8310 " file1.txt",
8311 " file2.txt",
8312 "> dir2",
8313 " file4.txt",
8314 ],
8315 "Should be able to expand directories even when root is hidden"
8316 );
8317 }
8318
8319 // Test 3: Multiple worktrees with hide_root = true
8320 {
8321 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8322 let window =
8323 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8324 let workspace = window
8325 .read_with(cx, |mw, _| mw.workspace().clone())
8326 .unwrap();
8327 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8328
8329 // Set hide_root to true
8330 cx.update(|_, cx| {
8331 let settings = *ProjectPanelSettings::get_global(cx);
8332 ProjectPanelSettings::override_global(
8333 ProjectPanelSettings {
8334 hide_root: true,
8335 ..settings
8336 },
8337 cx,
8338 );
8339 });
8340
8341 let panel = workspace.update_in(cx, ProjectPanel::new);
8342 cx.run_until_parked();
8343
8344 assert_eq!(
8345 visible_entries_as_strings(&panel, 0..10, cx),
8346 &[
8347 "v root1",
8348 " > dir1",
8349 " > dir2",
8350 " file4.txt",
8351 "v root2",
8352 " > dir3",
8353 " file6.txt",
8354 ],
8355 "With hide_root=true and multiple worktrees, roots should still be visible"
8356 );
8357 }
8358
8359 // Test 4: Multiple worktrees with hide_root = false
8360 {
8361 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8362 let window =
8363 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8364 let workspace = window
8365 .read_with(cx, |mw, _| mw.workspace().clone())
8366 .unwrap();
8367 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8368
8369 cx.update(|_, cx| {
8370 let settings = *ProjectPanelSettings::get_global(cx);
8371 ProjectPanelSettings::override_global(
8372 ProjectPanelSettings {
8373 hide_root: false,
8374 ..settings
8375 },
8376 cx,
8377 );
8378 });
8379
8380 let panel = workspace.update_in(cx, ProjectPanel::new);
8381 cx.run_until_parked();
8382
8383 assert_eq!(
8384 visible_entries_as_strings(&panel, 0..10, cx),
8385 &[
8386 "v root1",
8387 " > dir1",
8388 " > dir2",
8389 " file4.txt",
8390 "v root2",
8391 " > dir3",
8392 " file6.txt",
8393 ],
8394 "With hide_root=false and multiple worktrees, roots should be visible"
8395 );
8396 }
8397}
8398
8399#[gpui::test]
8400async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
8401 init_test_with_editor(cx);
8402
8403 let fs = FakeFs::new(cx.executor());
8404 fs.insert_tree(
8405 "/root",
8406 json!({
8407 "file1.txt": "content of file1",
8408 "file2.txt": "content of file2",
8409 "dir1": {
8410 "file3.txt": "content of file3"
8411 }
8412 }),
8413 )
8414 .await;
8415
8416 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8417 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8418 let workspace = window
8419 .read_with(cx, |mw, _| mw.workspace().clone())
8420 .unwrap();
8421 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8422 let panel = workspace.update_in(cx, ProjectPanel::new);
8423 cx.run_until_parked();
8424
8425 let file1_path = "root/file1.txt";
8426 let file2_path = "root/file2.txt";
8427 select_path_with_mark(&panel, file1_path, cx);
8428 select_path_with_mark(&panel, file2_path, cx);
8429
8430 panel.update_in(cx, |panel, window, cx| {
8431 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
8432 });
8433 cx.executor().run_until_parked();
8434
8435 workspace.update_in(cx, |workspace, _, cx| {
8436 let active_items = workspace
8437 .panes()
8438 .iter()
8439 .filter_map(|pane| pane.read(cx).active_item())
8440 .collect::<Vec<_>>();
8441 assert_eq!(active_items.len(), 1);
8442 let diff_view = active_items
8443 .into_iter()
8444 .next()
8445 .unwrap()
8446 .downcast::<FileDiffView>()
8447 .expect("Open item should be an FileDiffView");
8448 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
8449 assert_eq!(
8450 diff_view.tab_tooltip_text(cx).unwrap(),
8451 format!(
8452 "{} ↔ {}",
8453 rel_path(file1_path).display(PathStyle::local()),
8454 rel_path(file2_path).display(PathStyle::local())
8455 )
8456 );
8457 });
8458
8459 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
8460 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
8461 let worktree_id = panel.update(cx, |panel, cx| {
8462 panel
8463 .project
8464 .read(cx)
8465 .worktrees(cx)
8466 .next()
8467 .unwrap()
8468 .read(cx)
8469 .id()
8470 });
8471
8472 let expected_entries = [
8473 SelectedEntry {
8474 worktree_id,
8475 entry_id: file1_entry_id,
8476 },
8477 SelectedEntry {
8478 worktree_id,
8479 entry_id: file2_entry_id,
8480 },
8481 ];
8482 panel.update(cx, |panel, _cx| {
8483 assert_eq!(
8484 &panel.marked_entries, &expected_entries,
8485 "Should keep marked entries after comparison"
8486 );
8487 });
8488
8489 panel.update(cx, |panel, cx| {
8490 panel.project.update(cx, |_, cx| {
8491 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
8492 })
8493 });
8494
8495 panel.update(cx, |panel, _cx| {
8496 assert_eq!(
8497 &panel.marked_entries, &expected_entries,
8498 "Marked entries should persist after focusing back on the project panel"
8499 );
8500 });
8501}
8502
8503#[gpui::test]
8504async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
8505 init_test_with_editor(cx);
8506
8507 let fs = FakeFs::new(cx.executor());
8508 fs.insert_tree(
8509 "/root",
8510 json!({
8511 "file1.txt": "content of file1",
8512 "file2.txt": "content of file2",
8513 "dir1": {},
8514 "dir2": {
8515 "file3.txt": "content of file3"
8516 }
8517 }),
8518 )
8519 .await;
8520
8521 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8522 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8523 let workspace = window
8524 .read_with(cx, |mw, _| mw.workspace().clone())
8525 .unwrap();
8526 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8527 let panel = workspace.update_in(cx, ProjectPanel::new);
8528 cx.run_until_parked();
8529
8530 // Test 1: When only one file is selected, there should be no compare option
8531 select_path(&panel, "root/file1.txt", cx);
8532
8533 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8534 assert_eq!(
8535 selected_files, None,
8536 "Should not have compare option when only one file is selected"
8537 );
8538
8539 // Test 2: When multiple files are selected, there should be a compare option
8540 select_path_with_mark(&panel, "root/file1.txt", cx);
8541 select_path_with_mark(&panel, "root/file2.txt", cx);
8542
8543 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8544 assert!(
8545 selected_files.is_some(),
8546 "Should have files selected for comparison"
8547 );
8548 if let Some((file1, file2)) = selected_files {
8549 assert!(
8550 file1.to_string_lossy().ends_with("file1.txt")
8551 && file2.to_string_lossy().ends_with("file2.txt"),
8552 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
8553 );
8554 }
8555
8556 // Test 3: Selecting a directory shouldn't count as a comparable file
8557 select_path_with_mark(&panel, "root/dir1", cx);
8558
8559 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8560 assert!(
8561 selected_files.is_some(),
8562 "Directory selection should not affect comparable files"
8563 );
8564 if let Some((file1, file2)) = selected_files {
8565 assert!(
8566 file1.to_string_lossy().ends_with("file1.txt")
8567 && file2.to_string_lossy().ends_with("file2.txt"),
8568 "Selecting a directory should not affect the number of comparable files"
8569 );
8570 }
8571
8572 // Test 4: Selecting one more file
8573 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
8574
8575 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8576 assert!(
8577 selected_files.is_some(),
8578 "Directory selection should not affect comparable files"
8579 );
8580 if let Some((file1, file2)) = selected_files {
8581 assert!(
8582 file1.to_string_lossy().ends_with("file2.txt")
8583 && file2.to_string_lossy().ends_with("file3.txt"),
8584 "Selecting a directory should not affect the number of comparable files"
8585 );
8586 }
8587}
8588
8589#[gpui::test]
8590async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
8591 init_test(cx);
8592
8593 let fs = FakeFs::new(cx.executor());
8594 fs.insert_tree(
8595 "/root",
8596 json!({
8597 ".hidden-file.txt": "hidden file content",
8598 "visible-file.txt": "visible file content",
8599 ".hidden-parent-dir": {
8600 "nested-dir": {
8601 "file.txt": "file content",
8602 }
8603 },
8604 "visible-dir": {
8605 "file-in-visible.txt": "file content",
8606 "nested": {
8607 ".hidden-nested-dir": {
8608 ".double-hidden-dir": {
8609 "deep-file-1.txt": "deep content 1",
8610 "deep-file-2.txt": "deep content 2"
8611 },
8612 "hidden-nested-file-1.txt": "hidden nested 1",
8613 "hidden-nested-file-2.txt": "hidden nested 2"
8614 },
8615 "visible-nested-file.txt": "visible nested content"
8616 }
8617 }
8618 }),
8619 )
8620 .await;
8621
8622 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8623 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8624 let workspace = window
8625 .read_with(cx, |mw, _| mw.workspace().clone())
8626 .unwrap();
8627 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8628
8629 cx.update(|_, cx| {
8630 let settings = *ProjectPanelSettings::get_global(cx);
8631 ProjectPanelSettings::override_global(
8632 ProjectPanelSettings {
8633 hide_hidden: false,
8634 ..settings
8635 },
8636 cx,
8637 );
8638 });
8639
8640 let panel = workspace.update_in(cx, ProjectPanel::new);
8641 cx.run_until_parked();
8642
8643 toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
8644 toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
8645 toggle_expand_dir(&panel, "root/visible-dir", cx);
8646 toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
8647 toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
8648 toggle_expand_dir(
8649 &panel,
8650 "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
8651 cx,
8652 );
8653
8654 let expanded = [
8655 "v root",
8656 " v .hidden-parent-dir",
8657 " v nested-dir",
8658 " file.txt",
8659 " v visible-dir",
8660 " v nested",
8661 " v .hidden-nested-dir",
8662 " v .double-hidden-dir <== selected",
8663 " deep-file-1.txt",
8664 " deep-file-2.txt",
8665 " hidden-nested-file-1.txt",
8666 " hidden-nested-file-2.txt",
8667 " visible-nested-file.txt",
8668 " file-in-visible.txt",
8669 " .hidden-file.txt",
8670 " visible-file.txt",
8671 ];
8672
8673 assert_eq!(
8674 visible_entries_as_strings(&panel, 0..30, cx),
8675 &expanded,
8676 "With hide_hidden=false, contents of hidden nested directory should be visible"
8677 );
8678
8679 cx.update(|_, cx| {
8680 let settings = *ProjectPanelSettings::get_global(cx);
8681 ProjectPanelSettings::override_global(
8682 ProjectPanelSettings {
8683 hide_hidden: true,
8684 ..settings
8685 },
8686 cx,
8687 );
8688 });
8689
8690 panel.update_in(cx, |panel, window, cx| {
8691 panel.update_visible_entries(None, false, false, window, cx);
8692 });
8693 cx.run_until_parked();
8694
8695 assert_eq!(
8696 visible_entries_as_strings(&panel, 0..30, cx),
8697 &[
8698 "v root",
8699 " v visible-dir",
8700 " v nested",
8701 " visible-nested-file.txt",
8702 " file-in-visible.txt",
8703 " visible-file.txt",
8704 ],
8705 "With hide_hidden=false, contents of hidden nested directory should be visible"
8706 );
8707
8708 panel.update_in(cx, |panel, window, cx| {
8709 let settings = *ProjectPanelSettings::get_global(cx);
8710 ProjectPanelSettings::override_global(
8711 ProjectPanelSettings {
8712 hide_hidden: false,
8713 ..settings
8714 },
8715 cx,
8716 );
8717 panel.update_visible_entries(None, false, false, window, cx);
8718 });
8719 cx.run_until_parked();
8720
8721 assert_eq!(
8722 visible_entries_as_strings(&panel, 0..30, cx),
8723 &expanded,
8724 "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
8725 );
8726}
8727
8728fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8729 let path = rel_path(path);
8730 panel.update_in(cx, |panel, window, cx| {
8731 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8732 let worktree = worktree.read(cx);
8733 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8734 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8735 panel.update_visible_entries(
8736 Some((worktree.id(), entry_id)),
8737 false,
8738 false,
8739 window,
8740 cx,
8741 );
8742 return;
8743 }
8744 }
8745 panic!("no worktree for path {:?}", path);
8746 });
8747 cx.run_until_parked();
8748}
8749
8750fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
8751 let path = rel_path(path);
8752 panel.update(cx, |panel, cx| {
8753 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8754 let worktree = worktree.read(cx);
8755 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8756 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8757 let entry = crate::SelectedEntry {
8758 worktree_id: worktree.id(),
8759 entry_id,
8760 };
8761 if !panel.marked_entries.contains(&entry) {
8762 panel.marked_entries.push(entry);
8763 }
8764 panel.selection = Some(entry);
8765 return;
8766 }
8767 }
8768 panic!("no worktree for path {:?}", path);
8769 });
8770}
8771
8772/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
8773/// `active_ancestor_path` is the path to the folded component that should be active.
8774fn select_folded_path_with_mark(
8775 panel: &Entity<ProjectPanel>,
8776 leaf_path: &str,
8777 active_ancestor_path: &str,
8778 cx: &mut VisualTestContext,
8779) {
8780 select_path_with_mark(panel, leaf_path, cx);
8781 set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
8782}
8783
8784fn set_folded_active_ancestor(
8785 panel: &Entity<ProjectPanel>,
8786 leaf_path: &str,
8787 active_ancestor_path: &str,
8788 cx: &mut VisualTestContext,
8789) {
8790 let leaf_path = rel_path(leaf_path);
8791 let active_ancestor_path = rel_path(active_ancestor_path);
8792 panel.update(cx, |panel, cx| {
8793 let mut leaf_entry_id = None;
8794 let mut target_entry_id = None;
8795
8796 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8797 let worktree = worktree.read(cx);
8798 if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
8799 leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8800 }
8801 if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
8802 target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
8803 }
8804 }
8805
8806 let leaf_entry_id =
8807 leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
8808 let target_entry_id = target_entry_id
8809 .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
8810 let folded_ancestors = panel
8811 .state
8812 .ancestors
8813 .get_mut(&leaf_entry_id)
8814 .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
8815 let ancestor_ids = folded_ancestors.ancestors.clone();
8816
8817 let mut depth_for_target = None;
8818 for depth in 0..ancestor_ids.len() {
8819 let resolved_entry_id = if depth == 0 {
8820 leaf_entry_id
8821 } else {
8822 ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
8823 };
8824 if resolved_entry_id == target_entry_id {
8825 depth_for_target = Some(depth);
8826 break;
8827 }
8828 }
8829
8830 folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
8831 panic!(
8832 "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
8833 )
8834 });
8835 });
8836}
8837
8838fn drag_selection_to(
8839 panel: &Entity<ProjectPanel>,
8840 target_path: &str,
8841 is_file: bool,
8842 cx: &mut VisualTestContext,
8843) {
8844 let target_entry = find_project_entry(panel, target_path, cx)
8845 .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
8846
8847 panel.update_in(cx, |panel, window, cx| {
8848 let selection = panel
8849 .selection
8850 .expect("a selection is required before dragging");
8851 let drag = DraggedSelection {
8852 active_selection: SelectedEntry {
8853 worktree_id: selection.worktree_id,
8854 entry_id: panel.resolve_entry(selection.entry_id),
8855 },
8856 marked_selections: Arc::from(panel.marked_entries.clone()),
8857 };
8858 panel.drag_onto(&drag, target_entry, is_file, window, cx);
8859 });
8860 cx.executor().run_until_parked();
8861}
8862
8863fn find_project_entry(
8864 panel: &Entity<ProjectPanel>,
8865 path: &str,
8866 cx: &mut VisualTestContext,
8867) -> Option<ProjectEntryId> {
8868 let path = rel_path(path);
8869 panel.update(cx, |panel, cx| {
8870 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8871 let worktree = worktree.read(cx);
8872 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8873 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8874 }
8875 }
8876 panic!("no worktree for path {path:?}");
8877 })
8878}
8879
8880fn visible_entries_as_strings(
8881 panel: &Entity<ProjectPanel>,
8882 range: Range<usize>,
8883 cx: &mut VisualTestContext,
8884) -> Vec<String> {
8885 let mut result = Vec::new();
8886 let mut project_entries = HashSet::default();
8887 let mut has_editor = false;
8888
8889 panel.update_in(cx, |panel, window, cx| {
8890 panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
8891 if details.is_editing {
8892 assert!(!has_editor, "duplicate editor entry");
8893 has_editor = true;
8894 } else {
8895 assert!(
8896 project_entries.insert(project_entry),
8897 "duplicate project entry {:?} {:?}",
8898 project_entry,
8899 details
8900 );
8901 }
8902
8903 let indent = " ".repeat(details.depth);
8904 let icon = if details.kind.is_dir() {
8905 if details.is_expanded { "v " } else { "> " }
8906 } else {
8907 " "
8908 };
8909 #[cfg(windows)]
8910 let filename = details.filename.replace("\\", "/");
8911 #[cfg(not(windows))]
8912 let filename = details.filename;
8913 let name = if details.is_editing {
8914 format!("[EDITOR: '{}']", filename)
8915 } else if details.is_processing {
8916 format!("[PROCESSING: '{}']", filename)
8917 } else {
8918 filename
8919 };
8920 let selected = if details.is_selected {
8921 " <== selected"
8922 } else {
8923 ""
8924 };
8925 let marked = if details.is_marked {
8926 " <== marked"
8927 } else {
8928 ""
8929 };
8930
8931 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8932 });
8933 });
8934
8935 result
8936}
8937
8938/// Test that missing sort_mode field defaults to DirectoriesFirst
8939#[gpui::test]
8940async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
8941 init_test(cx);
8942
8943 // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
8944 let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
8945 assert_eq!(
8946 default_settings.sort_mode,
8947 settings::ProjectPanelSortMode::DirectoriesFirst,
8948 "sort_mode should default to DirectoriesFirst"
8949 );
8950}
8951
8952/// Test sort modes: DirectoriesFirst (default) vs Mixed
8953#[gpui::test]
8954async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
8955 init_test(cx);
8956
8957 let fs = FakeFs::new(cx.executor());
8958 fs.insert_tree(
8959 "/root",
8960 json!({
8961 "zebra.txt": "",
8962 "Apple": {},
8963 "banana.rs": "",
8964 "Carrot": {},
8965 "aardvark.txt": "",
8966 }),
8967 )
8968 .await;
8969
8970 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8971 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8972 let workspace = window
8973 .read_with(cx, |mw, _| mw.workspace().clone())
8974 .unwrap();
8975 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8976 let panel = workspace.update_in(cx, ProjectPanel::new);
8977 cx.run_until_parked();
8978
8979 // Default sort mode should be DirectoriesFirst
8980 assert_eq!(
8981 visible_entries_as_strings(&panel, 0..50, cx),
8982 &[
8983 "v root",
8984 " > Apple",
8985 " > Carrot",
8986 " aardvark.txt",
8987 " banana.rs",
8988 " zebra.txt",
8989 ]
8990 );
8991}
8992
8993#[gpui::test]
8994async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
8995 init_test(cx);
8996
8997 let fs = FakeFs::new(cx.executor());
8998 fs.insert_tree(
8999 "/root",
9000 json!({
9001 "Zebra.txt": "",
9002 "apple": {},
9003 "Banana.rs": "",
9004 "carrot": {},
9005 "Aardvark.txt": "",
9006 }),
9007 )
9008 .await;
9009
9010 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9011 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9012 let workspace = window
9013 .read_with(cx, |mw, _| mw.workspace().clone())
9014 .unwrap();
9015 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9016
9017 // Switch to Mixed mode
9018 cx.update(|_, cx| {
9019 cx.update_global::<SettingsStore, _>(|store, cx| {
9020 store.update_user_settings(cx, |settings| {
9021 settings.project_panel.get_or_insert_default().sort_mode =
9022 Some(settings::ProjectPanelSortMode::Mixed);
9023 });
9024 });
9025 });
9026
9027 let panel = workspace.update_in(cx, ProjectPanel::new);
9028 cx.run_until_parked();
9029
9030 // Mixed mode: case-insensitive sorting
9031 // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
9032 assert_eq!(
9033 visible_entries_as_strings(&panel, 0..50, cx),
9034 &[
9035 "v root",
9036 " Aardvark.txt",
9037 " > apple",
9038 " Banana.rs",
9039 " > carrot",
9040 " Zebra.txt",
9041 ]
9042 );
9043}
9044
9045#[gpui::test]
9046async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
9047 init_test(cx);
9048
9049 let fs = FakeFs::new(cx.executor());
9050 fs.insert_tree(
9051 "/root",
9052 json!({
9053 "Zebra.txt": "",
9054 "apple": {},
9055 "Banana.rs": "",
9056 "carrot": {},
9057 "Aardvark.txt": "",
9058 }),
9059 )
9060 .await;
9061
9062 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9063 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9064 let workspace = window
9065 .read_with(cx, |mw, _| mw.workspace().clone())
9066 .unwrap();
9067 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9068
9069 // Switch to FilesFirst mode
9070 cx.update(|_, cx| {
9071 cx.update_global::<SettingsStore, _>(|store, cx| {
9072 store.update_user_settings(cx, |settings| {
9073 settings.project_panel.get_or_insert_default().sort_mode =
9074 Some(settings::ProjectPanelSortMode::FilesFirst);
9075 });
9076 });
9077 });
9078
9079 let panel = workspace.update_in(cx, ProjectPanel::new);
9080 cx.run_until_parked();
9081
9082 // FilesFirst mode: files first, then directories (both case-insensitive)
9083 assert_eq!(
9084 visible_entries_as_strings(&panel, 0..50, cx),
9085 &[
9086 "v root",
9087 " Aardvark.txt",
9088 " Banana.rs",
9089 " Zebra.txt",
9090 " > apple",
9091 " > carrot",
9092 ]
9093 );
9094}
9095
9096#[gpui::test]
9097async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
9098 init_test(cx);
9099
9100 let fs = FakeFs::new(cx.executor());
9101 fs.insert_tree(
9102 "/root",
9103 json!({
9104 "file2.txt": "",
9105 "dir1": {},
9106 "file1.txt": "",
9107 }),
9108 )
9109 .await;
9110
9111 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9112 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9113 let workspace = window
9114 .read_with(cx, |mw, _| mw.workspace().clone())
9115 .unwrap();
9116 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9117 let panel = workspace.update_in(cx, ProjectPanel::new);
9118 cx.run_until_parked();
9119
9120 // Initially DirectoriesFirst
9121 assert_eq!(
9122 visible_entries_as_strings(&panel, 0..50, cx),
9123 &["v root", " > dir1", " file1.txt", " file2.txt",]
9124 );
9125
9126 // Toggle to Mixed
9127 cx.update(|_, cx| {
9128 cx.update_global::<SettingsStore, _>(|store, cx| {
9129 store.update_user_settings(cx, |settings| {
9130 settings.project_panel.get_or_insert_default().sort_mode =
9131 Some(settings::ProjectPanelSortMode::Mixed);
9132 });
9133 });
9134 });
9135 cx.run_until_parked();
9136
9137 assert_eq!(
9138 visible_entries_as_strings(&panel, 0..50, cx),
9139 &["v root", " > dir1", " file1.txt", " file2.txt",]
9140 );
9141
9142 // Toggle back to DirectoriesFirst
9143 cx.update(|_, cx| {
9144 cx.update_global::<SettingsStore, _>(|store, cx| {
9145 store.update_user_settings(cx, |settings| {
9146 settings.project_panel.get_or_insert_default().sort_mode =
9147 Some(settings::ProjectPanelSortMode::DirectoriesFirst);
9148 });
9149 });
9150 });
9151 cx.run_until_parked();
9152
9153 assert_eq!(
9154 visible_entries_as_strings(&panel, 0..50, cx),
9155 &["v root", " > dir1", " file1.txt", " file2.txt",]
9156 );
9157}
9158
9159#[gpui::test]
9160async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
9161 cx: &mut gpui::TestAppContext,
9162) {
9163 init_test(cx);
9164
9165 // parent: accept
9166 run_create_file_in_folded_path_case(
9167 "parent",
9168 "root1/parent",
9169 "file_in_parent.txt",
9170 &[
9171 "v root1",
9172 " v parent",
9173 " > subdir/child",
9174 " [EDITOR: ''] <== selected",
9175 ],
9176 &[
9177 "v root1",
9178 " v parent",
9179 " > subdir/child",
9180 " file_in_parent.txt <== selected <== marked",
9181 ],
9182 true,
9183 cx,
9184 )
9185 .await;
9186
9187 // parent: cancel
9188 run_create_file_in_folded_path_case(
9189 "parent",
9190 "root1/parent",
9191 "file_in_parent.txt",
9192 &[
9193 "v root1",
9194 " v parent",
9195 " > subdir/child",
9196 " [EDITOR: ''] <== selected",
9197 ],
9198 &["v root1", " > parent/subdir/child <== selected"],
9199 false,
9200 cx,
9201 )
9202 .await;
9203
9204 // subdir: accept
9205 run_create_file_in_folded_path_case(
9206 "subdir",
9207 "root1/parent/subdir",
9208 "file_in_subdir.txt",
9209 &[
9210 "v root1",
9211 " v parent/subdir",
9212 " > child",
9213 " [EDITOR: ''] <== selected",
9214 ],
9215 &[
9216 "v root1",
9217 " v parent/subdir",
9218 " > child",
9219 " file_in_subdir.txt <== selected <== marked",
9220 ],
9221 true,
9222 cx,
9223 )
9224 .await;
9225
9226 // subdir: cancel
9227 run_create_file_in_folded_path_case(
9228 "subdir",
9229 "root1/parent/subdir",
9230 "file_in_subdir.txt",
9231 &[
9232 "v root1",
9233 " v parent/subdir",
9234 " > child",
9235 " [EDITOR: ''] <== selected",
9236 ],
9237 &["v root1", " > parent/subdir/child <== selected"],
9238 false,
9239 cx,
9240 )
9241 .await;
9242
9243 // child: accept
9244 run_create_file_in_folded_path_case(
9245 "child",
9246 "root1/parent/subdir/child",
9247 "file_in_child.txt",
9248 &[
9249 "v root1",
9250 " v parent/subdir/child",
9251 " [EDITOR: ''] <== selected",
9252 ],
9253 &[
9254 "v root1",
9255 " v parent/subdir/child",
9256 " file_in_child.txt <== selected <== marked",
9257 ],
9258 true,
9259 cx,
9260 )
9261 .await;
9262
9263 // child: cancel
9264 run_create_file_in_folded_path_case(
9265 "child",
9266 "root1/parent/subdir/child",
9267 "file_in_child.txt",
9268 &[
9269 "v root1",
9270 " v parent/subdir/child",
9271 " [EDITOR: ''] <== selected",
9272 ],
9273 &["v root1", " v parent/subdir/child <== selected"],
9274 false,
9275 cx,
9276 )
9277 .await;
9278}
9279
9280#[gpui::test]
9281async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
9282 cx: &mut gpui::TestAppContext,
9283) {
9284 init_test(cx);
9285
9286 let fs = FakeFs::new(cx.executor());
9287 fs.insert_tree(
9288 "/root1",
9289 json!({
9290 "parent": {
9291 "subdir": {
9292 "child": {},
9293 }
9294 }
9295 }),
9296 )
9297 .await;
9298
9299 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9300 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9301 let workspace = window
9302 .read_with(cx, |mw, _| mw.workspace().clone())
9303 .unwrap();
9304 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9305
9306 let panel = workspace.update_in(cx, |workspace, window, cx| {
9307 let panel = ProjectPanel::new(workspace, window, cx);
9308 workspace.add_panel(panel.clone(), window, cx);
9309 panel
9310 });
9311
9312 cx.update(|_, cx| {
9313 let settings = *ProjectPanelSettings::get_global(cx);
9314 ProjectPanelSettings::override_global(
9315 ProjectPanelSettings {
9316 auto_fold_dirs: true,
9317 ..settings
9318 },
9319 cx,
9320 );
9321 });
9322
9323 panel.update_in(cx, |panel, window, cx| {
9324 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9325 });
9326 cx.run_until_parked();
9327
9328 select_folded_path_with_mark(
9329 &panel,
9330 "root1/parent/subdir/child",
9331 "root1/parent/subdir",
9332 cx,
9333 );
9334 panel.update(cx, |panel, _| {
9335 panel.marked_entries.clear();
9336 });
9337
9338 let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
9339 .expect("parent directory should exist for this test");
9340 let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
9341 .expect("subdir directory should exist for this test");
9342 let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
9343 .expect("child directory should exist for this test");
9344
9345 panel.update(cx, |panel, _| {
9346 let selection = panel
9347 .selection
9348 .expect("leaf directory should be selected before creating a new entry");
9349 assert_eq!(
9350 selection.entry_id, child_entry_id,
9351 "initial selection should be the folded leaf entry"
9352 );
9353 assert_eq!(
9354 panel.resolve_entry(selection.entry_id),
9355 subdir_entry_id,
9356 "active folded component should start at subdir"
9357 );
9358 });
9359
9360 panel.update_in(cx, |panel, window, cx| {
9361 panel.deploy_context_menu(
9362 gpui::point(gpui::px(1.), gpui::px(1.)),
9363 child_entry_id,
9364 window,
9365 cx,
9366 );
9367 panel.new_file(&NewFile, window, cx);
9368 });
9369 cx.run_until_parked();
9370 panel.update_in(cx, |panel, window, cx| {
9371 assert!(panel.filename_editor.read(cx).is_focused(window));
9372 });
9373 cx.run_until_parked();
9374
9375 set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
9376
9377 panel.update_in(cx, |panel, window, cx| {
9378 panel.deploy_context_menu(
9379 gpui::point(gpui::px(2.), gpui::px(2.)),
9380 subdir_entry_id,
9381 window,
9382 cx,
9383 );
9384 });
9385 cx.run_until_parked();
9386
9387 panel.update(cx, |panel, _| {
9388 assert!(
9389 panel.state.edit_state.is_none(),
9390 "opening another context menu should blur the filename editor and discard edit state"
9391 );
9392 let selection = panel
9393 .selection
9394 .expect("selection should restore to the previously focused leaf entry");
9395 assert_eq!(
9396 selection.entry_id, child_entry_id,
9397 "blur-driven cancellation should restore the previous leaf selection"
9398 );
9399 assert_eq!(
9400 panel.resolve_entry(selection.entry_id),
9401 parent_entry_id,
9402 "temporary unfolded pending state should preserve the active ancestor chosen before blur"
9403 );
9404 });
9405
9406 panel.update_in(cx, |panel, window, cx| {
9407 panel.new_file(&NewFile, window, cx);
9408 });
9409 cx.run_until_parked();
9410 assert_eq!(
9411 visible_entries_as_strings(&panel, 0..10, cx),
9412 &[
9413 "v root1",
9414 " v parent",
9415 " > subdir/child",
9416 " [EDITOR: ''] <== selected",
9417 ],
9418 "new file after blur should use the preserved active ancestor"
9419 );
9420 panel.update(cx, |panel, _| {
9421 let edit_state = panel
9422 .state
9423 .edit_state
9424 .as_ref()
9425 .expect("new file should enter edit state");
9426 assert_eq!(
9427 edit_state.temporarily_unfolded,
9428 Some(parent_entry_id),
9429 "temporary unfolding should now target parent after restoring the active ancestor"
9430 );
9431 });
9432
9433 let file_name = "created_after_blur.txt";
9434 panel
9435 .update_in(cx, |panel, window, cx| {
9436 panel.filename_editor.update(cx, |editor, cx| {
9437 editor.set_text(file_name, window, cx);
9438 });
9439 panel.confirm_edit(true, window, cx).expect(
9440 "confirm_edit should start creation for the file created after blur transition",
9441 )
9442 })
9443 .await
9444 .expect("creating file after blur transition should succeed");
9445 cx.run_until_parked();
9446
9447 assert!(
9448 fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
9449 .await,
9450 "file should be created under parent after active ancestor is restored to parent"
9451 );
9452 assert!(
9453 !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
9454 .await,
9455 "file should not be created under subdir when parent is the active ancestor"
9456 );
9457}
9458
9459async fn run_create_file_in_folded_path_case(
9460 case_name: &str,
9461 active_ancestor_path: &str,
9462 created_file_name: &str,
9463 expected_temporary_state: &[&str],
9464 expected_final_state: &[&str],
9465 accept_creation: bool,
9466 cx: &mut gpui::TestAppContext,
9467) {
9468 let expected_collapsed_state = &["v root1", " > parent/subdir/child <== selected"];
9469
9470 let fs = FakeFs::new(cx.executor());
9471 fs.insert_tree(
9472 "/root1",
9473 json!({
9474 "parent": {
9475 "subdir": {
9476 "child": {},
9477 }
9478 }
9479 }),
9480 )
9481 .await;
9482
9483 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9484 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9485 let workspace = window
9486 .read_with(cx, |mw, _| mw.workspace().clone())
9487 .unwrap();
9488 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9489
9490 let panel = workspace.update_in(cx, |workspace, window, cx| {
9491 let panel = ProjectPanel::new(workspace, window, cx);
9492 workspace.add_panel(panel.clone(), window, cx);
9493 panel
9494 });
9495
9496 cx.update(|_, cx| {
9497 let settings = *ProjectPanelSettings::get_global(cx);
9498 ProjectPanelSettings::override_global(
9499 ProjectPanelSettings {
9500 auto_fold_dirs: true,
9501 ..settings
9502 },
9503 cx,
9504 );
9505 });
9506
9507 panel.update_in(cx, |panel, window, cx| {
9508 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9509 });
9510 cx.run_until_parked();
9511
9512 select_folded_path_with_mark(
9513 &panel,
9514 "root1/parent/subdir/child",
9515 active_ancestor_path,
9516 cx,
9517 );
9518 panel.update(cx, |panel, _| {
9519 panel.marked_entries.clear();
9520 });
9521
9522 assert_eq!(
9523 visible_entries_as_strings(&panel, 0..10, cx),
9524 expected_collapsed_state,
9525 "case '{}' should start from a folded state",
9526 case_name
9527 );
9528
9529 panel.update_in(cx, |panel, window, cx| {
9530 panel.new_file(&NewFile, window, cx);
9531 });
9532 cx.run_until_parked();
9533 panel.update_in(cx, |panel, window, cx| {
9534 assert!(panel.filename_editor.read(cx).is_focused(window));
9535 });
9536 cx.run_until_parked();
9537 assert_eq!(
9538 visible_entries_as_strings(&panel, 0..10, cx),
9539 expected_temporary_state,
9540 "case '{}' ({}) should temporarily unfold the active ancestor while editing",
9541 case_name,
9542 if accept_creation { "accept" } else { "cancel" }
9543 );
9544
9545 let relative_directory = active_ancestor_path
9546 .strip_prefix("root1/")
9547 .expect("active_ancestor_path should start with root1/");
9548 let created_file_path = PathBuf::from("/root1")
9549 .join(relative_directory)
9550 .join(created_file_name);
9551
9552 if accept_creation {
9553 panel
9554 .update_in(cx, |panel, window, cx| {
9555 panel.filename_editor.update(cx, |editor, cx| {
9556 editor.set_text(created_file_name, window, cx);
9557 });
9558 panel.confirm_edit(true, window, cx).unwrap()
9559 })
9560 .await
9561 .unwrap();
9562 cx.run_until_parked();
9563
9564 assert_eq!(
9565 visible_entries_as_strings(&panel, 0..10, cx),
9566 expected_final_state,
9567 "case '{}' should keep the newly created file selected and marked after accept",
9568 case_name
9569 );
9570 assert!(
9571 fs.is_file(created_file_path.as_path()).await,
9572 "case '{}' should create file '{}'",
9573 case_name,
9574 created_file_path.display()
9575 );
9576 } else {
9577 panel.update_in(cx, |panel, window, cx| {
9578 panel.cancel(&Cancel, window, cx);
9579 });
9580 cx.run_until_parked();
9581
9582 assert_eq!(
9583 visible_entries_as_strings(&panel, 0..10, cx),
9584 expected_final_state,
9585 "case '{}' should keep the expected panel state after cancel",
9586 case_name
9587 );
9588 assert!(
9589 !fs.is_file(created_file_path.as_path()).await,
9590 "case '{}' should not create a file after cancel",
9591 case_name
9592 );
9593 }
9594}
9595
9596fn init_test(cx: &mut TestAppContext) {
9597 cx.update(|cx| {
9598 let settings_store = SettingsStore::test(cx);
9599 cx.set_global(settings_store);
9600 theme::init(theme::LoadThemes::JustBase, cx);
9601 crate::init(cx);
9602
9603 cx.update_global::<SettingsStore, _>(|store, cx| {
9604 store.update_user_settings(cx, |settings| {
9605 settings
9606 .project_panel
9607 .get_or_insert_default()
9608 .auto_fold_dirs = Some(false);
9609 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
9610 });
9611 });
9612 });
9613}
9614
9615fn init_test_with_editor(cx: &mut TestAppContext) {
9616 cx.update(|cx| {
9617 let app_state = AppState::test(cx);
9618 theme::init(theme::LoadThemes::JustBase, cx);
9619 editor::init(cx);
9620 crate::init(cx);
9621 workspace::init(app_state, cx);
9622
9623 cx.update_global::<SettingsStore, _>(|store, cx| {
9624 store.update_user_settings(cx, |settings| {
9625 settings
9626 .project_panel
9627 .get_or_insert_default()
9628 .auto_fold_dirs = Some(false);
9629 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
9630 });
9631 });
9632 });
9633}
9634
9635fn set_auto_open_settings(
9636 cx: &mut TestAppContext,
9637 auto_open_settings: ProjectPanelAutoOpenSettings,
9638) {
9639 cx.update(|cx| {
9640 cx.update_global::<SettingsStore, _>(|store, cx| {
9641 store.update_user_settings(cx, |settings| {
9642 settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
9643 });
9644 })
9645 });
9646}
9647
9648fn ensure_single_file_is_opened(
9649 workspace: &Entity<Workspace>,
9650 expected_path: &str,
9651 cx: &mut VisualTestContext,
9652) {
9653 workspace.update_in(cx, |workspace, _, cx| {
9654 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9655 assert_eq!(worktrees.len(), 1);
9656 let worktree_id = worktrees[0].read(cx).id();
9657
9658 let open_project_paths = workspace
9659 .panes()
9660 .iter()
9661 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9662 .collect::<Vec<_>>();
9663 assert_eq!(
9664 open_project_paths,
9665 vec![ProjectPath {
9666 worktree_id,
9667 path: Arc::from(rel_path(expected_path))
9668 }],
9669 "Should have opened file, selected in project panel"
9670 );
9671 });
9672}
9673
9674fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9675 assert!(
9676 !cx.has_pending_prompt(),
9677 "Should have no prompts before the deletion"
9678 );
9679 panel.update_in(cx, |panel, window, cx| {
9680 panel.delete(&Delete { skip_prompt: false }, window, cx)
9681 });
9682 assert!(
9683 cx.has_pending_prompt(),
9684 "Should have a prompt after the deletion"
9685 );
9686 cx.simulate_prompt_answer("Delete");
9687 assert!(
9688 !cx.has_pending_prompt(),
9689 "Should have no prompts after prompt was replied to"
9690 );
9691 cx.executor().run_until_parked();
9692}
9693
9694fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9695 assert!(
9696 !cx.has_pending_prompt(),
9697 "Should have no prompts before the deletion"
9698 );
9699 panel.update_in(cx, |panel, window, cx| {
9700 panel.delete(&Delete { skip_prompt: true }, window, cx)
9701 });
9702 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9703 cx.executor().run_until_parked();
9704}
9705
9706fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
9707 assert!(
9708 !cx.has_pending_prompt(),
9709 "Should have no prompts after deletion operation closes the file"
9710 );
9711 workspace.update_in(cx, |workspace, _window, cx| {
9712 let open_project_paths = workspace
9713 .panes()
9714 .iter()
9715 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9716 .collect::<Vec<_>>();
9717 assert!(
9718 open_project_paths.is_empty(),
9719 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9720 );
9721 });
9722}
9723
9724struct TestProjectItemView {
9725 focus_handle: FocusHandle,
9726 path: ProjectPath,
9727}
9728
9729struct TestProjectItem {
9730 path: ProjectPath,
9731}
9732
9733impl project::ProjectItem for TestProjectItem {
9734 fn try_open(
9735 _project: &Entity<Project>,
9736 path: &ProjectPath,
9737 cx: &mut App,
9738 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
9739 let path = path.clone();
9740 Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
9741 }
9742
9743 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9744 None
9745 }
9746
9747 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9748 Some(self.path.clone())
9749 }
9750
9751 fn is_dirty(&self) -> bool {
9752 false
9753 }
9754}
9755
9756impl ProjectItem for TestProjectItemView {
9757 type Item = TestProjectItem;
9758
9759 fn for_project_item(
9760 _: Entity<Project>,
9761 _: Option<&Pane>,
9762 project_item: Entity<Self::Item>,
9763 _: &mut Window,
9764 cx: &mut Context<Self>,
9765 ) -> Self
9766 where
9767 Self: Sized,
9768 {
9769 Self {
9770 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9771 focus_handle: cx.focus_handle(),
9772 }
9773 }
9774}
9775
9776impl Item for TestProjectItemView {
9777 type Event = ();
9778
9779 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
9780 "Test".into()
9781 }
9782}
9783
9784impl EventEmitter<()> for TestProjectItemView {}
9785
9786impl Focusable for TestProjectItemView {
9787 fn focus_handle(&self, _: &App) -> FocusHandle {
9788 self.focus_handle.clone()
9789 }
9790}
9791
9792impl Render for TestProjectItemView {
9793 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9794 Empty
9795 }
9796}