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