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