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