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, test::TestItem},
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(0),
1258 "Should select from the beginning of the filename"
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_reveal_in_project_panel_notifications(cx: &mut gpui::TestAppContext) {
6020 init_test_with_editor(cx);
6021 let fs = FakeFs::new(cx.background_executor.clone());
6022 fs.insert_tree(
6023 "/workspace",
6024 json!({
6025 "README.md": ""
6026 }),
6027 )
6028 .await;
6029
6030 let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
6031 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6032 let workspace = window
6033 .read_with(cx, |mw, _| mw.workspace().clone())
6034 .unwrap();
6035 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6036 let panel = workspace.update_in(cx, ProjectPanel::new);
6037 cx.run_until_parked();
6038
6039 // Ensure that, attempting to run `pane: reveal in project panel` without
6040 // any active item does nothing, i.e., does not focus the project panel but
6041 // it also does not show a notification.
6042 cx.dispatch_action(workspace::RevealInProjectPanel::default());
6043 cx.run_until_parked();
6044
6045 panel.update_in(cx, |panel, window, cx| {
6046 assert!(
6047 !panel.focus_handle(cx).is_focused(window),
6048 "Project panel should not be focused after attempting to reveal an invisible worktree entry"
6049 );
6050
6051 panel.workspace.update(cx, |workspace, cx| {
6052 assert!(
6053 workspace.active_item(cx).is_none(),
6054 "Workspace should not have an active item"
6055 );
6056 assert_eq!(
6057 workspace.notification_ids(),
6058 vec![],
6059 "No notification should be shown when there's no active item"
6060 );
6061 }).unwrap();
6062 });
6063
6064 // Create a file in a different folder than the one in the project so we can
6065 // later open it and ensure that, attempting to reveal it in the project
6066 // panel shows a notification and does not focus the project panel.
6067 fs.insert_tree(
6068 "/external",
6069 json!({
6070 "file.txt": "External File",
6071 }),
6072 )
6073 .await;
6074
6075 let (worktree, _) = project
6076 .update(cx, |project, cx| {
6077 project.find_or_create_worktree("/external/file.txt", false, cx)
6078 })
6079 .await
6080 .unwrap();
6081
6082 workspace
6083 .update_in(cx, |workspace, window, cx| {
6084 let worktree_id = worktree.read(cx).id();
6085 let path = rel_path("").into();
6086 let project_path = ProjectPath { worktree_id, path };
6087
6088 workspace.open_path(project_path, None, true, window, cx)
6089 })
6090 .await
6091 .unwrap();
6092 cx.run_until_parked();
6093
6094 cx.dispatch_action(workspace::RevealInProjectPanel::default());
6095 cx.run_until_parked();
6096
6097 panel.update_in(cx, |panel, window, cx| {
6098 assert!(
6099 !panel.focus_handle(cx).is_focused(window),
6100 "Project panel should not be focused after attempting to reveal an invisible worktree entry"
6101 );
6102
6103 panel.workspace.update(cx, |workspace, cx| {
6104 assert!(
6105 workspace.active_item(cx).is_some(),
6106 "Workspace should have an active item"
6107 );
6108
6109 let notification_ids = workspace.notification_ids();
6110 assert_eq!(
6111 notification_ids.len(),
6112 1,
6113 "A notification should be shown when trying to reveal an invisible worktree entry"
6114 );
6115
6116 workspace.dismiss_notification(¬ification_ids[0], cx);
6117 assert_eq!(
6118 workspace.notification_ids().len(),
6119 0,
6120 "No notifications should be left after dismissing"
6121 );
6122 }).unwrap();
6123 });
6124
6125 // Create an empty buffer so we can ensure that, attempting to reveal it in
6126 // the project panel shows a notification and does not focus the project
6127 // panel.
6128 let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
6129 pane.update_in(cx, |pane, window, cx| {
6130 let item = cx.new(|cx| TestItem::new(cx).with_label("Unsaved buffer"));
6131 pane.add_item(Box::new(item), false, false, None, window, cx);
6132 });
6133
6134 cx.dispatch_action(workspace::RevealInProjectPanel::default());
6135 cx.run_until_parked();
6136
6137 panel.update_in(cx, |panel, window, cx| {
6138 assert!(
6139 !panel.focus_handle(cx).is_focused(window),
6140 "Project panel should not be focused after attempting to reveal an unsaved buffer"
6141 );
6142
6143 panel
6144 .workspace
6145 .update(cx, |workspace, cx| {
6146 assert!(
6147 workspace.active_item(cx).is_some(),
6148 "Workspace should have an active item"
6149 );
6150
6151 let notification_ids = workspace.notification_ids();
6152 assert_eq!(
6153 notification_ids.len(),
6154 1,
6155 "A notification should be shown when trying to reveal an unsaved buffer"
6156 );
6157 })
6158 .unwrap();
6159 });
6160}
6161
6162#[gpui::test]
6163async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6164 init_test(cx);
6165 cx.update(|cx| {
6166 cx.update_global::<SettingsStore, _>(|store, cx| {
6167 store.update_user_settings(cx, |settings| {
6168 settings.project.worktree.file_scan_exclusions =
6169 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6170 });
6171 });
6172 });
6173
6174 cx.update(|cx| {
6175 register_project_item::<TestProjectItemView>(cx);
6176 });
6177
6178 let fs = FakeFs::new(cx.executor());
6179 fs.insert_tree(
6180 "/root1",
6181 json!({
6182 ".dockerignore": "",
6183 ".git": {
6184 "HEAD": "",
6185 },
6186 }),
6187 )
6188 .await;
6189
6190 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6191 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6192 let workspace = window
6193 .read_with(cx, |mw, _| mw.workspace().clone())
6194 .unwrap();
6195 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6196 let panel = workspace.update_in(cx, |workspace, window, cx| {
6197 let panel = ProjectPanel::new(workspace, window, cx);
6198 workspace.add_panel(panel.clone(), window, cx);
6199 panel
6200 });
6201 cx.run_until_parked();
6202
6203 select_path(&panel, "root1", cx);
6204 assert_eq!(
6205 visible_entries_as_strings(&panel, 0..10, cx),
6206 &["v root1 <== selected", " .dockerignore",]
6207 );
6208 workspace.update_in(cx, |workspace, _, cx| {
6209 assert!(
6210 workspace.active_item(cx).is_none(),
6211 "Should have no active items in the beginning"
6212 );
6213 });
6214
6215 let excluded_file_path = ".git/COMMIT_EDITMSG";
6216 let excluded_dir_path = "excluded_dir";
6217
6218 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6219 cx.run_until_parked();
6220 panel.update_in(cx, |panel, window, cx| {
6221 assert!(panel.filename_editor.read(cx).is_focused(window));
6222 });
6223 panel
6224 .update_in(cx, |panel, window, cx| {
6225 panel.filename_editor.update(cx, |editor, cx| {
6226 editor.set_text(excluded_file_path, window, cx)
6227 });
6228 panel.confirm_edit(true, window, cx).unwrap()
6229 })
6230 .await
6231 .unwrap();
6232
6233 assert_eq!(
6234 visible_entries_as_strings(&panel, 0..13, cx),
6235 &["v root1", " .dockerignore"],
6236 "Excluded dir should not be shown after opening a file in it"
6237 );
6238 panel.update_in(cx, |panel, window, cx| {
6239 assert!(
6240 !panel.filename_editor.read(cx).is_focused(window),
6241 "Should have closed the file name editor"
6242 );
6243 });
6244 workspace.update_in(cx, |workspace, _, cx| {
6245 let active_entry_path = workspace
6246 .active_item(cx)
6247 .expect("should have opened and activated the excluded item")
6248 .act_as::<TestProjectItemView>(cx)
6249 .expect("should have opened the corresponding project item for the excluded item")
6250 .read(cx)
6251 .path
6252 .clone();
6253 assert_eq!(
6254 active_entry_path.path.as_ref(),
6255 rel_path(excluded_file_path),
6256 "Should open the excluded file"
6257 );
6258
6259 assert!(
6260 workspace.notification_ids().is_empty(),
6261 "Should have no notifications after opening an excluded file"
6262 );
6263 });
6264 assert!(
6265 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
6266 "Should have created the excluded file"
6267 );
6268
6269 select_path(&panel, "root1", cx);
6270 panel.update_in(cx, |panel, window, cx| {
6271 panel.new_directory(&NewDirectory, window, cx)
6272 });
6273 cx.run_until_parked();
6274 panel.update_in(cx, |panel, window, cx| {
6275 assert!(panel.filename_editor.read(cx).is_focused(window));
6276 });
6277 panel
6278 .update_in(cx, |panel, window, cx| {
6279 panel.filename_editor.update(cx, |editor, cx| {
6280 editor.set_text(excluded_file_path, window, cx)
6281 });
6282 panel.confirm_edit(true, window, cx).unwrap()
6283 })
6284 .await
6285 .unwrap();
6286 cx.run_until_parked();
6287 assert_eq!(
6288 visible_entries_as_strings(&panel, 0..13, cx),
6289 &["v root1", " .dockerignore"],
6290 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
6291 );
6292 panel.update_in(cx, |panel, window, cx| {
6293 assert!(
6294 !panel.filename_editor.read(cx).is_focused(window),
6295 "Should have closed the file name editor"
6296 );
6297 });
6298 workspace.update_in(cx, |workspace, _, cx| {
6299 let notifications = workspace.notification_ids();
6300 assert_eq!(
6301 notifications.len(),
6302 1,
6303 "Should receive one notification with the error message"
6304 );
6305 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6306 assert!(workspace.notification_ids().is_empty());
6307 });
6308
6309 select_path(&panel, "root1", cx);
6310 panel.update_in(cx, |panel, window, cx| {
6311 panel.new_directory(&NewDirectory, window, cx)
6312 });
6313 cx.run_until_parked();
6314
6315 panel.update_in(cx, |panel, window, cx| {
6316 assert!(panel.filename_editor.read(cx).is_focused(window));
6317 });
6318
6319 panel
6320 .update_in(cx, |panel, window, cx| {
6321 panel.filename_editor.update(cx, |editor, cx| {
6322 editor.set_text(excluded_dir_path, window, cx)
6323 });
6324 panel.confirm_edit(true, window, cx).unwrap()
6325 })
6326 .await
6327 .unwrap();
6328
6329 cx.run_until_parked();
6330
6331 assert_eq!(
6332 visible_entries_as_strings(&panel, 0..13, cx),
6333 &["v root1", " .dockerignore"],
6334 "Should not change the project panel after trying to create an excluded directory"
6335 );
6336 panel.update_in(cx, |panel, window, cx| {
6337 assert!(
6338 !panel.filename_editor.read(cx).is_focused(window),
6339 "Should have closed the file name editor"
6340 );
6341 });
6342 workspace.update_in(cx, |workspace, _, cx| {
6343 let notifications = workspace.notification_ids();
6344 assert_eq!(
6345 notifications.len(),
6346 1,
6347 "Should receive one notification explaining that no directory is actually shown"
6348 );
6349 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6350 assert!(workspace.notification_ids().is_empty());
6351 });
6352 assert!(
6353 fs.is_dir(Path::new("/root1/excluded_dir")).await,
6354 "Should have created the excluded directory"
6355 );
6356}
6357
6358#[gpui::test]
6359async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
6360 init_test_with_editor(cx);
6361
6362 let fs = FakeFs::new(cx.executor());
6363 fs.insert_tree(
6364 "/src",
6365 json!({
6366 "test": {
6367 "first.rs": "// First Rust file",
6368 "second.rs": "// Second Rust file",
6369 "third.rs": "// Third Rust file",
6370 }
6371 }),
6372 )
6373 .await;
6374
6375 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6376 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6377 let workspace = window
6378 .read_with(cx, |mw, _| mw.workspace().clone())
6379 .unwrap();
6380 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6381 let panel = workspace.update_in(cx, |workspace, window, cx| {
6382 let panel = ProjectPanel::new(workspace, window, cx);
6383 workspace.add_panel(panel.clone(), window, cx);
6384 panel
6385 });
6386 cx.run_until_parked();
6387
6388 select_path(&panel, "src", cx);
6389 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6390 cx.executor().run_until_parked();
6391 assert_eq!(
6392 visible_entries_as_strings(&panel, 0..10, cx),
6393 &[
6394 //
6395 "v src <== selected",
6396 " > test"
6397 ]
6398 );
6399 panel.update_in(cx, |panel, window, cx| {
6400 panel.new_directory(&NewDirectory, window, cx)
6401 });
6402 cx.executor().run_until_parked();
6403 panel.update_in(cx, |panel, window, cx| {
6404 assert!(panel.filename_editor.read(cx).is_focused(window));
6405 });
6406 assert_eq!(
6407 visible_entries_as_strings(&panel, 0..10, cx),
6408 &[
6409 //
6410 "v src",
6411 " > [EDITOR: ''] <== selected",
6412 " > test"
6413 ]
6414 );
6415
6416 panel.update_in(cx, |panel, window, cx| {
6417 panel.cancel(&menu::Cancel, window, cx);
6418 });
6419 cx.executor().run_until_parked();
6420 assert_eq!(
6421 visible_entries_as_strings(&panel, 0..10, cx),
6422 &[
6423 //
6424 "v src <== selected",
6425 " > test"
6426 ]
6427 );
6428
6429 panel.update_in(cx, |panel, window, cx| {
6430 panel.new_directory(&NewDirectory, window, cx)
6431 });
6432 cx.executor().run_until_parked();
6433 panel.update_in(cx, |panel, window, cx| {
6434 assert!(panel.filename_editor.read(cx).is_focused(window));
6435 });
6436 assert_eq!(
6437 visible_entries_as_strings(&panel, 0..10, cx),
6438 &[
6439 //
6440 "v src",
6441 " > [EDITOR: ''] <== selected",
6442 " > test"
6443 ]
6444 );
6445 workspace.update_in(cx, |_, window, _| window.blur());
6446 cx.executor().run_until_parked();
6447 assert_eq!(
6448 visible_entries_as_strings(&panel, 0..10, cx),
6449 &[
6450 //
6451 "v src <== selected",
6452 " > test"
6453 ]
6454 );
6455}
6456
6457#[gpui::test]
6458async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
6459 init_test_with_editor(cx);
6460
6461 let fs = FakeFs::new(cx.executor());
6462 fs.insert_tree(
6463 "/root",
6464 json!({
6465 "dir1": {
6466 "subdir1": {},
6467 "file1.txt": "",
6468 "file2.txt": "",
6469 },
6470 "dir2": {
6471 "subdir2": {},
6472 "file3.txt": "",
6473 "file4.txt": "",
6474 },
6475 "file5.txt": "",
6476 "file6.txt": "",
6477 }),
6478 )
6479 .await;
6480
6481 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6482 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6483 let workspace = window
6484 .read_with(cx, |mw, _| mw.workspace().clone())
6485 .unwrap();
6486 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6487 let panel = workspace.update_in(cx, ProjectPanel::new);
6488 cx.run_until_parked();
6489
6490 toggle_expand_dir(&panel, "root/dir1", cx);
6491 toggle_expand_dir(&panel, "root/dir2", cx);
6492
6493 // Test Case 1: Delete middle file in directory
6494 select_path(&panel, "root/dir1/file1.txt", cx);
6495 assert_eq!(
6496 visible_entries_as_strings(&panel, 0..15, cx),
6497 &[
6498 "v root",
6499 " v dir1",
6500 " > subdir1",
6501 " file1.txt <== selected",
6502 " file2.txt",
6503 " v dir2",
6504 " > subdir2",
6505 " file3.txt",
6506 " file4.txt",
6507 " file5.txt",
6508 " file6.txt",
6509 ],
6510 "Initial state before deleting middle file"
6511 );
6512
6513 submit_deletion(&panel, cx);
6514 assert_eq!(
6515 visible_entries_as_strings(&panel, 0..15, cx),
6516 &[
6517 "v root",
6518 " v dir1",
6519 " > subdir1",
6520 " file2.txt <== selected",
6521 " v dir2",
6522 " > subdir2",
6523 " file3.txt",
6524 " file4.txt",
6525 " file5.txt",
6526 " file6.txt",
6527 ],
6528 "Should select next file after deleting middle file"
6529 );
6530
6531 // Test Case 2: Delete last file in directory
6532 submit_deletion(&panel, cx);
6533 assert_eq!(
6534 visible_entries_as_strings(&panel, 0..15, cx),
6535 &[
6536 "v root",
6537 " v dir1",
6538 " > subdir1 <== selected",
6539 " v dir2",
6540 " > subdir2",
6541 " file3.txt",
6542 " file4.txt",
6543 " file5.txt",
6544 " file6.txt",
6545 ],
6546 "Should select next directory when last file is deleted"
6547 );
6548
6549 // Test Case 3: Delete root level file
6550 select_path(&panel, "root/file6.txt", cx);
6551 assert_eq!(
6552 visible_entries_as_strings(&panel, 0..15, cx),
6553 &[
6554 "v root",
6555 " v dir1",
6556 " > subdir1",
6557 " v dir2",
6558 " > subdir2",
6559 " file3.txt",
6560 " file4.txt",
6561 " file5.txt",
6562 " file6.txt <== selected",
6563 ],
6564 "Initial state before deleting root level file"
6565 );
6566
6567 submit_deletion(&panel, cx);
6568 assert_eq!(
6569 visible_entries_as_strings(&panel, 0..15, cx),
6570 &[
6571 "v root",
6572 " v dir1",
6573 " > subdir1",
6574 " v dir2",
6575 " > subdir2",
6576 " file3.txt",
6577 " file4.txt",
6578 " file5.txt <== selected",
6579 ],
6580 "Should select prev entry at root level"
6581 );
6582}
6583
6584#[gpui::test]
6585async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
6586 init_test_with_editor(cx);
6587
6588 let fs = FakeFs::new(cx.executor());
6589 fs.insert_tree(
6590 path!("/root"),
6591 json!({
6592 "aa": "// Testing 1",
6593 "bb": "// Testing 2",
6594 "cc": "// Testing 3",
6595 "dd": "// Testing 4",
6596 "ee": "// Testing 5",
6597 "ff": "// Testing 6",
6598 "gg": "// Testing 7",
6599 "hh": "// Testing 8",
6600 "ii": "// Testing 8",
6601 ".gitignore": "bb\ndd\nee\nff\nii\n'",
6602 }),
6603 )
6604 .await;
6605
6606 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6607 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6608 let workspace = window
6609 .read_with(cx, |mw, _| mw.workspace().clone())
6610 .unwrap();
6611 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6612
6613 // Test 1: Auto selection with one gitignored file next to the deleted file
6614 cx.update(|_, cx| {
6615 let settings = *ProjectPanelSettings::get_global(cx);
6616 ProjectPanelSettings::override_global(
6617 ProjectPanelSettings {
6618 hide_gitignore: true,
6619 ..settings
6620 },
6621 cx,
6622 );
6623 });
6624
6625 let panel = workspace.update_in(cx, ProjectPanel::new);
6626 cx.run_until_parked();
6627
6628 select_path(&panel, "root/aa", cx);
6629 assert_eq!(
6630 visible_entries_as_strings(&panel, 0..10, cx),
6631 &[
6632 "v root",
6633 " .gitignore",
6634 " aa <== selected",
6635 " cc",
6636 " gg",
6637 " hh"
6638 ],
6639 "Initial state should hide files on .gitignore"
6640 );
6641
6642 submit_deletion(&panel, cx);
6643
6644 assert_eq!(
6645 visible_entries_as_strings(&panel, 0..10, cx),
6646 &[
6647 "v root",
6648 " .gitignore",
6649 " cc <== selected",
6650 " gg",
6651 " hh"
6652 ],
6653 "Should select next entry not on .gitignore"
6654 );
6655
6656 // Test 2: Auto selection with many gitignored files next to the deleted file
6657 submit_deletion(&panel, cx);
6658 assert_eq!(
6659 visible_entries_as_strings(&panel, 0..10, cx),
6660 &[
6661 "v root",
6662 " .gitignore",
6663 " gg <== selected",
6664 " hh"
6665 ],
6666 "Should select next entry not on .gitignore"
6667 );
6668
6669 // Test 3: Auto selection of entry before deleted file
6670 select_path(&panel, "root/hh", cx);
6671 assert_eq!(
6672 visible_entries_as_strings(&panel, 0..10, cx),
6673 &[
6674 "v root",
6675 " .gitignore",
6676 " gg",
6677 " hh <== selected"
6678 ],
6679 "Should select next entry not on .gitignore"
6680 );
6681 submit_deletion(&panel, cx);
6682 assert_eq!(
6683 visible_entries_as_strings(&panel, 0..10, cx),
6684 &["v root", " .gitignore", " gg <== selected"],
6685 "Should select next entry not on .gitignore"
6686 );
6687}
6688
6689#[gpui::test]
6690async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
6691 init_test_with_editor(cx);
6692
6693 let fs = FakeFs::new(cx.executor());
6694 fs.insert_tree(
6695 path!("/root"),
6696 json!({
6697 "dir1": {
6698 "file1": "// Testing",
6699 "file2": "// Testing",
6700 "file3": "// Testing"
6701 },
6702 "aa": "// Testing",
6703 ".gitignore": "file1\nfile3\n",
6704 }),
6705 )
6706 .await;
6707
6708 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6709 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6710 let workspace = window
6711 .read_with(cx, |mw, _| mw.workspace().clone())
6712 .unwrap();
6713 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6714
6715 cx.update(|_, cx| {
6716 let settings = *ProjectPanelSettings::get_global(cx);
6717 ProjectPanelSettings::override_global(
6718 ProjectPanelSettings {
6719 hide_gitignore: true,
6720 ..settings
6721 },
6722 cx,
6723 );
6724 });
6725
6726 let panel = workspace.update_in(cx, ProjectPanel::new);
6727 cx.run_until_parked();
6728
6729 // Test 1: Visible items should exclude files on gitignore
6730 toggle_expand_dir(&panel, "root/dir1", cx);
6731 select_path(&panel, "root/dir1/file2", cx);
6732 assert_eq!(
6733 visible_entries_as_strings(&panel, 0..10, cx),
6734 &[
6735 "v root",
6736 " v dir1",
6737 " file2 <== selected",
6738 " .gitignore",
6739 " aa"
6740 ],
6741 "Initial state should hide files on .gitignore"
6742 );
6743 submit_deletion(&panel, cx);
6744
6745 // Test 2: Auto selection should go to the parent
6746 assert_eq!(
6747 visible_entries_as_strings(&panel, 0..10, cx),
6748 &[
6749 "v root",
6750 " v dir1 <== selected",
6751 " .gitignore",
6752 " aa"
6753 ],
6754 "Initial state should hide files on .gitignore"
6755 );
6756}
6757
6758#[gpui::test]
6759async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
6760 init_test_with_editor(cx);
6761
6762 let fs = FakeFs::new(cx.executor());
6763 fs.insert_tree(
6764 "/root",
6765 json!({
6766 "dir1": {
6767 "subdir1": {
6768 "a.txt": "",
6769 "b.txt": ""
6770 },
6771 "file1.txt": "",
6772 },
6773 "dir2": {
6774 "subdir2": {
6775 "c.txt": "",
6776 "d.txt": ""
6777 },
6778 "file2.txt": "",
6779 },
6780 "file3.txt": "",
6781 }),
6782 )
6783 .await;
6784
6785 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6786 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6787 let workspace = window
6788 .read_with(cx, |mw, _| mw.workspace().clone())
6789 .unwrap();
6790 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6791 let panel = workspace.update_in(cx, ProjectPanel::new);
6792 cx.run_until_parked();
6793
6794 toggle_expand_dir(&panel, "root/dir1", cx);
6795 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6796 toggle_expand_dir(&panel, "root/dir2", cx);
6797 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6798
6799 // Test Case 1: Select and delete nested directory with parent
6800 cx.simulate_modifiers_change(gpui::Modifiers {
6801 control: true,
6802 ..Default::default()
6803 });
6804 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6805 select_path_with_mark(&panel, "root/dir1", cx);
6806
6807 assert_eq!(
6808 visible_entries_as_strings(&panel, 0..15, cx),
6809 &[
6810 "v root",
6811 " v dir1 <== selected <== marked",
6812 " v subdir1 <== marked",
6813 " a.txt",
6814 " b.txt",
6815 " file1.txt",
6816 " v dir2",
6817 " v subdir2",
6818 " c.txt",
6819 " d.txt",
6820 " file2.txt",
6821 " file3.txt",
6822 ],
6823 "Initial state before deleting nested directory with parent"
6824 );
6825
6826 submit_deletion(&panel, cx);
6827 assert_eq!(
6828 visible_entries_as_strings(&panel, 0..15, cx),
6829 &[
6830 "v root",
6831 " v dir2 <== selected",
6832 " v subdir2",
6833 " c.txt",
6834 " d.txt",
6835 " file2.txt",
6836 " file3.txt",
6837 ],
6838 "Should select next directory after deleting directory with parent"
6839 );
6840
6841 // Test Case 2: Select mixed files and directories across levels
6842 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
6843 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
6844 select_path_with_mark(&panel, "root/file3.txt", cx);
6845
6846 assert_eq!(
6847 visible_entries_as_strings(&panel, 0..15, cx),
6848 &[
6849 "v root",
6850 " v dir2",
6851 " v subdir2",
6852 " c.txt <== marked",
6853 " d.txt",
6854 " file2.txt <== marked",
6855 " file3.txt <== selected <== marked",
6856 ],
6857 "Initial state before deleting"
6858 );
6859
6860 submit_deletion(&panel, cx);
6861 assert_eq!(
6862 visible_entries_as_strings(&panel, 0..15, cx),
6863 &[
6864 "v root",
6865 " v dir2 <== selected",
6866 " v subdir2",
6867 " d.txt",
6868 ],
6869 "Should select sibling directory"
6870 );
6871}
6872
6873#[gpui::test]
6874async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
6875 init_test_with_editor(cx);
6876
6877 let fs = FakeFs::new(cx.executor());
6878 fs.insert_tree(
6879 "/root",
6880 json!({
6881 "dir1": {
6882 "subdir1": {
6883 "a.txt": "",
6884 "b.txt": ""
6885 },
6886 "file1.txt": "",
6887 },
6888 "dir2": {
6889 "subdir2": {
6890 "c.txt": "",
6891 "d.txt": ""
6892 },
6893 "file2.txt": "",
6894 },
6895 "file3.txt": "",
6896 "file4.txt": "",
6897 }),
6898 )
6899 .await;
6900
6901 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6902 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6903 let workspace = window
6904 .read_with(cx, |mw, _| mw.workspace().clone())
6905 .unwrap();
6906 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6907 let panel = workspace.update_in(cx, ProjectPanel::new);
6908 cx.run_until_parked();
6909
6910 toggle_expand_dir(&panel, "root/dir1", cx);
6911 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6912 toggle_expand_dir(&panel, "root/dir2", cx);
6913 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6914
6915 // Test Case 1: Select all root files and directories
6916 cx.simulate_modifiers_change(gpui::Modifiers {
6917 control: true,
6918 ..Default::default()
6919 });
6920 select_path_with_mark(&panel, "root/dir1", cx);
6921 select_path_with_mark(&panel, "root/dir2", cx);
6922 select_path_with_mark(&panel, "root/file3.txt", cx);
6923 select_path_with_mark(&panel, "root/file4.txt", cx);
6924 assert_eq!(
6925 visible_entries_as_strings(&panel, 0..20, cx),
6926 &[
6927 "v root",
6928 " v dir1 <== marked",
6929 " v subdir1",
6930 " a.txt",
6931 " b.txt",
6932 " file1.txt",
6933 " v dir2 <== marked",
6934 " v subdir2",
6935 " c.txt",
6936 " d.txt",
6937 " file2.txt",
6938 " file3.txt <== marked",
6939 " file4.txt <== selected <== marked",
6940 ],
6941 "State before deleting all contents"
6942 );
6943
6944 submit_deletion(&panel, cx);
6945 assert_eq!(
6946 visible_entries_as_strings(&panel, 0..20, cx),
6947 &["v root <== selected"],
6948 "Only empty root directory should remain after deleting all contents"
6949 );
6950}
6951
6952#[gpui::test]
6953async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
6954 init_test_with_editor(cx);
6955
6956 let fs = FakeFs::new(cx.executor());
6957 fs.insert_tree(
6958 "/root",
6959 json!({
6960 "dir1": {
6961 "subdir1": {
6962 "file_a.txt": "content a",
6963 "file_b.txt": "content b",
6964 },
6965 "subdir2": {
6966 "file_c.txt": "content c",
6967 },
6968 "file1.txt": "content 1",
6969 },
6970 "dir2": {
6971 "file2.txt": "content 2",
6972 },
6973 }),
6974 )
6975 .await;
6976
6977 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6978 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6979 let workspace = window
6980 .read_with(cx, |mw, _| mw.workspace().clone())
6981 .unwrap();
6982 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6983 let panel = workspace.update_in(cx, ProjectPanel::new);
6984 cx.run_until_parked();
6985
6986 toggle_expand_dir(&panel, "root/dir1", cx);
6987 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6988 toggle_expand_dir(&panel, "root/dir2", cx);
6989 cx.simulate_modifiers_change(gpui::Modifiers {
6990 control: true,
6991 ..Default::default()
6992 });
6993
6994 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6995 select_path_with_mark(&panel, "root/dir1", cx);
6996 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6997 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6998
6999 assert_eq!(
7000 visible_entries_as_strings(&panel, 0..20, cx),
7001 &[
7002 "v root",
7003 " v dir1 <== marked",
7004 " v subdir1 <== marked",
7005 " file_a.txt <== selected <== marked",
7006 " file_b.txt",
7007 " > subdir2",
7008 " file1.txt",
7009 " v dir2",
7010 " file2.txt",
7011 ],
7012 "State with parent dir, subdir, and file selected"
7013 );
7014 submit_deletion(&panel, cx);
7015 assert_eq!(
7016 visible_entries_as_strings(&panel, 0..20, cx),
7017 &["v root", " v dir2 <== selected", " file2.txt",],
7018 "Only dir2 should remain after deletion"
7019 );
7020}
7021
7022#[gpui::test]
7023async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7024 init_test_with_editor(cx);
7025
7026 let fs = FakeFs::new(cx.executor());
7027 // First worktree
7028 fs.insert_tree(
7029 "/root1",
7030 json!({
7031 "dir1": {
7032 "file1.txt": "content 1",
7033 "file2.txt": "content 2",
7034 },
7035 "dir2": {
7036 "file3.txt": "content 3",
7037 },
7038 }),
7039 )
7040 .await;
7041
7042 // Second worktree
7043 fs.insert_tree(
7044 "/root2",
7045 json!({
7046 "dir3": {
7047 "file4.txt": "content 4",
7048 "file5.txt": "content 5",
7049 },
7050 "file6.txt": "content 6",
7051 }),
7052 )
7053 .await;
7054
7055 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7056 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7057 let workspace = window
7058 .read_with(cx, |mw, _| mw.workspace().clone())
7059 .unwrap();
7060 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7061 let panel = workspace.update_in(cx, ProjectPanel::new);
7062 cx.run_until_parked();
7063
7064 // Expand all directories for testing
7065 toggle_expand_dir(&panel, "root1/dir1", cx);
7066 toggle_expand_dir(&panel, "root1/dir2", cx);
7067 toggle_expand_dir(&panel, "root2/dir3", cx);
7068
7069 // Test Case 1: Delete files across different worktrees
7070 cx.simulate_modifiers_change(gpui::Modifiers {
7071 control: true,
7072 ..Default::default()
7073 });
7074 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7075 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7076
7077 assert_eq!(
7078 visible_entries_as_strings(&panel, 0..20, cx),
7079 &[
7080 "v root1",
7081 " v dir1",
7082 " file1.txt <== marked",
7083 " file2.txt",
7084 " v dir2",
7085 " file3.txt",
7086 "v root2",
7087 " v dir3",
7088 " file4.txt <== selected <== marked",
7089 " file5.txt",
7090 " file6.txt",
7091 ],
7092 "Initial state with files selected from different worktrees"
7093 );
7094
7095 submit_deletion(&panel, cx);
7096 assert_eq!(
7097 visible_entries_as_strings(&panel, 0..20, cx),
7098 &[
7099 "v root1",
7100 " v dir1",
7101 " file2.txt",
7102 " v dir2",
7103 " file3.txt",
7104 "v root2",
7105 " v dir3",
7106 " file5.txt <== selected",
7107 " file6.txt",
7108 ],
7109 "Should select next file in the last worktree after deletion"
7110 );
7111
7112 // Test Case 2: Delete directories from different worktrees
7113 select_path_with_mark(&panel, "root1/dir1", cx);
7114 select_path_with_mark(&panel, "root2/dir3", cx);
7115
7116 assert_eq!(
7117 visible_entries_as_strings(&panel, 0..20, cx),
7118 &[
7119 "v root1",
7120 " v dir1 <== marked",
7121 " file2.txt",
7122 " v dir2",
7123 " file3.txt",
7124 "v root2",
7125 " v dir3 <== selected <== marked",
7126 " file5.txt",
7127 " file6.txt",
7128 ],
7129 "State with directories marked from different worktrees"
7130 );
7131
7132 submit_deletion(&panel, cx);
7133 assert_eq!(
7134 visible_entries_as_strings(&panel, 0..20, cx),
7135 &[
7136 "v root1",
7137 " v dir2",
7138 " file3.txt",
7139 "v root2",
7140 " file6.txt <== selected",
7141 ],
7142 "Should select remaining file in last worktree after directory deletion"
7143 );
7144
7145 // Test Case 4: Delete all remaining files except roots
7146 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7147 select_path_with_mark(&panel, "root2/file6.txt", cx);
7148
7149 assert_eq!(
7150 visible_entries_as_strings(&panel, 0..20, cx),
7151 &[
7152 "v root1",
7153 " v dir2",
7154 " file3.txt <== marked",
7155 "v root2",
7156 " file6.txt <== selected <== marked",
7157 ],
7158 "State with all remaining files marked"
7159 );
7160
7161 submit_deletion(&panel, cx);
7162 assert_eq!(
7163 visible_entries_as_strings(&panel, 0..20, cx),
7164 &["v root1", " v dir2", "v root2 <== selected"],
7165 "Second parent root should be selected after deleting"
7166 );
7167}
7168
7169#[gpui::test]
7170async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
7171 init_test_with_editor(cx);
7172
7173 let fs = FakeFs::new(cx.executor());
7174 fs.insert_tree(
7175 "/root",
7176 json!({
7177 "dir1": {
7178 "file1.txt": "",
7179 "file2.txt": "",
7180 "file3.txt": "",
7181 },
7182 "dir2": {
7183 "file4.txt": "",
7184 "file5.txt": "",
7185 },
7186 }),
7187 )
7188 .await;
7189
7190 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7191 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7192 let workspace = window
7193 .read_with(cx, |mw, _| mw.workspace().clone())
7194 .unwrap();
7195 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7196 let panel = workspace.update_in(cx, ProjectPanel::new);
7197 cx.run_until_parked();
7198
7199 toggle_expand_dir(&panel, "root/dir1", cx);
7200 toggle_expand_dir(&panel, "root/dir2", cx);
7201
7202 cx.simulate_modifiers_change(gpui::Modifiers {
7203 control: true,
7204 ..Default::default()
7205 });
7206
7207 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
7208 select_path(&panel, "root/dir1/file1.txt", cx);
7209
7210 assert_eq!(
7211 visible_entries_as_strings(&panel, 0..15, cx),
7212 &[
7213 "v root",
7214 " v dir1",
7215 " file1.txt <== selected",
7216 " file2.txt <== marked",
7217 " file3.txt",
7218 " v dir2",
7219 " file4.txt",
7220 " file5.txt",
7221 ],
7222 "Initial state with one marked entry and different selection"
7223 );
7224
7225 // Delete should operate on the selected entry (file1.txt)
7226 submit_deletion(&panel, cx);
7227 assert_eq!(
7228 visible_entries_as_strings(&panel, 0..15, cx),
7229 &[
7230 "v root",
7231 " v dir1",
7232 " file2.txt <== selected <== marked",
7233 " file3.txt",
7234 " v dir2",
7235 " file4.txt",
7236 " file5.txt",
7237 ],
7238 "Should delete selected file, not marked file"
7239 );
7240
7241 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
7242 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
7243 select_path(&panel, "root/dir2/file5.txt", cx);
7244
7245 assert_eq!(
7246 visible_entries_as_strings(&panel, 0..15, cx),
7247 &[
7248 "v root",
7249 " v dir1",
7250 " file2.txt <== marked",
7251 " file3.txt <== marked",
7252 " v dir2",
7253 " file4.txt <== marked",
7254 " file5.txt <== selected",
7255 ],
7256 "Initial state with multiple marked entries and different selection"
7257 );
7258
7259 // Delete should operate on all marked entries, ignoring the selection
7260 submit_deletion(&panel, cx);
7261 assert_eq!(
7262 visible_entries_as_strings(&panel, 0..15, cx),
7263 &[
7264 "v root",
7265 " v dir1",
7266 " v dir2",
7267 " file5.txt <== selected",
7268 ],
7269 "Should delete all marked files, leaving only the selected file"
7270 );
7271}
7272
7273#[gpui::test]
7274async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7275 init_test_with_editor(cx);
7276
7277 let fs = FakeFs::new(cx.executor());
7278 fs.insert_tree(
7279 "/root_b",
7280 json!({
7281 "dir1": {
7282 "file1.txt": "content 1",
7283 "file2.txt": "content 2",
7284 },
7285 }),
7286 )
7287 .await;
7288
7289 fs.insert_tree(
7290 "/root_c",
7291 json!({
7292 "dir2": {},
7293 }),
7294 )
7295 .await;
7296
7297 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7298 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7299 let workspace = window
7300 .read_with(cx, |mw, _| mw.workspace().clone())
7301 .unwrap();
7302 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7303 let panel = workspace.update_in(cx, ProjectPanel::new);
7304 cx.run_until_parked();
7305
7306 toggle_expand_dir(&panel, "root_b/dir1", cx);
7307 toggle_expand_dir(&panel, "root_c/dir2", cx);
7308
7309 cx.simulate_modifiers_change(gpui::Modifiers {
7310 control: true,
7311 ..Default::default()
7312 });
7313 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7314 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7315
7316 assert_eq!(
7317 visible_entries_as_strings(&panel, 0..20, cx),
7318 &[
7319 "v root_b",
7320 " v dir1",
7321 " file1.txt <== marked",
7322 " file2.txt <== selected <== marked",
7323 "v root_c",
7324 " v dir2",
7325 ],
7326 "Initial state with files marked in root_b"
7327 );
7328
7329 submit_deletion(&panel, cx);
7330 assert_eq!(
7331 visible_entries_as_strings(&panel, 0..20, cx),
7332 &[
7333 "v root_b",
7334 " v dir1 <== selected",
7335 "v root_c",
7336 " v dir2",
7337 ],
7338 "After deletion in root_b as it's last deletion, selection should be in root_b"
7339 );
7340
7341 select_path_with_mark(&panel, "root_c/dir2", cx);
7342
7343 submit_deletion(&panel, cx);
7344 assert_eq!(
7345 visible_entries_as_strings(&panel, 0..20, cx),
7346 &["v root_b", " v dir1", "v root_c <== selected",],
7347 "After deleting from root_c, it should remain in root_c"
7348 );
7349}
7350
7351fn toggle_expand_dir(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
7352 let path = rel_path(path);
7353 panel.update_in(cx, |panel, window, cx| {
7354 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7355 let worktree = worktree.read(cx);
7356 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7357 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7358 panel.toggle_expanded(entry_id, window, cx);
7359 return;
7360 }
7361 }
7362 panic!("no worktree for path {:?}", path);
7363 });
7364 cx.run_until_parked();
7365}
7366
7367#[gpui::test]
7368async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
7369 init_test_with_editor(cx);
7370
7371 let fs = FakeFs::new(cx.executor());
7372 fs.insert_tree(
7373 path!("/root"),
7374 json!({
7375 ".gitignore": "**/ignored_dir\n**/ignored_nested",
7376 "dir1": {
7377 "empty1": {
7378 "empty2": {
7379 "empty3": {
7380 "file.txt": ""
7381 }
7382 }
7383 },
7384 "subdir1": {
7385 "file1.txt": "",
7386 "file2.txt": "",
7387 "ignored_nested": {
7388 "ignored_file.txt": ""
7389 }
7390 },
7391 "ignored_dir": {
7392 "subdir": {
7393 "deep_file.txt": ""
7394 }
7395 }
7396 }
7397 }),
7398 )
7399 .await;
7400
7401 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7402 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7403 let workspace = window
7404 .read_with(cx, |mw, _| mw.workspace().clone())
7405 .unwrap();
7406 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7407
7408 // Test 1: When auto-fold is enabled
7409 cx.update(|_, cx| {
7410 let settings = *ProjectPanelSettings::get_global(cx);
7411 ProjectPanelSettings::override_global(
7412 ProjectPanelSettings {
7413 auto_fold_dirs: true,
7414 ..settings
7415 },
7416 cx,
7417 );
7418 });
7419
7420 let panel = workspace.update_in(cx, ProjectPanel::new);
7421 cx.run_until_parked();
7422
7423 assert_eq!(
7424 visible_entries_as_strings(&panel, 0..20, cx),
7425 &["v root", " > dir1", " .gitignore",],
7426 "Initial state should show collapsed root structure"
7427 );
7428
7429 toggle_expand_dir(&panel, "root/dir1", cx);
7430 assert_eq!(
7431 visible_entries_as_strings(&panel, 0..20, cx),
7432 &[
7433 "v root",
7434 " v dir1 <== selected",
7435 " > empty1/empty2/empty3",
7436 " > ignored_dir",
7437 " > subdir1",
7438 " .gitignore",
7439 ],
7440 "Should show first level with auto-folded dirs and ignored dir visible"
7441 );
7442
7443 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7444 panel.update_in(cx, |panel, window, cx| {
7445 let project = panel.project.read(cx);
7446 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7447 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
7448 panel.update_visible_entries(None, false, false, window, cx);
7449 });
7450 cx.run_until_parked();
7451
7452 assert_eq!(
7453 visible_entries_as_strings(&panel, 0..20, cx),
7454 &[
7455 "v root",
7456 " v dir1 <== selected",
7457 " v empty1",
7458 " v empty2",
7459 " v empty3",
7460 " file.txt",
7461 " > ignored_dir",
7462 " v subdir1",
7463 " > ignored_nested",
7464 " file1.txt",
7465 " file2.txt",
7466 " .gitignore",
7467 ],
7468 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
7469 );
7470
7471 // Test 2: When auto-fold is disabled
7472 cx.update(|_, cx| {
7473 let settings = *ProjectPanelSettings::get_global(cx);
7474 ProjectPanelSettings::override_global(
7475 ProjectPanelSettings {
7476 auto_fold_dirs: false,
7477 ..settings
7478 },
7479 cx,
7480 );
7481 });
7482
7483 panel.update_in(cx, |panel, window, cx| {
7484 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
7485 });
7486
7487 toggle_expand_dir(&panel, "root/dir1", cx);
7488 assert_eq!(
7489 visible_entries_as_strings(&panel, 0..20, cx),
7490 &[
7491 "v root",
7492 " v dir1 <== selected",
7493 " > empty1",
7494 " > ignored_dir",
7495 " > subdir1",
7496 " .gitignore",
7497 ],
7498 "With auto-fold disabled: should show all directories separately"
7499 );
7500
7501 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7502 panel.update_in(cx, |panel, window, cx| {
7503 let project = panel.project.read(cx);
7504 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7505 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
7506 panel.update_visible_entries(None, false, false, window, cx);
7507 });
7508 cx.run_until_parked();
7509
7510 assert_eq!(
7511 visible_entries_as_strings(&panel, 0..20, cx),
7512 &[
7513 "v root",
7514 " v dir1 <== selected",
7515 " v empty1",
7516 " v empty2",
7517 " v empty3",
7518 " file.txt",
7519 " > ignored_dir",
7520 " v subdir1",
7521 " > ignored_nested",
7522 " file1.txt",
7523 " file2.txt",
7524 " .gitignore",
7525 ],
7526 "After expand_all without auto-fold: should expand all dirs normally, \
7527 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
7528 );
7529
7530 // Test 3: When explicitly called on ignored directory
7531 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
7532 panel.update_in(cx, |panel, window, cx| {
7533 let project = panel.project.read(cx);
7534 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7535 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
7536 panel.update_visible_entries(None, false, false, window, cx);
7537 });
7538 cx.run_until_parked();
7539
7540 assert_eq!(
7541 visible_entries_as_strings(&panel, 0..20, cx),
7542 &[
7543 "v root",
7544 " v dir1 <== selected",
7545 " v empty1",
7546 " v empty2",
7547 " v empty3",
7548 " file.txt",
7549 " v ignored_dir",
7550 " v subdir",
7551 " deep_file.txt",
7552 " v subdir1",
7553 " > ignored_nested",
7554 " file1.txt",
7555 " file2.txt",
7556 " .gitignore",
7557 ],
7558 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
7559 );
7560}
7561
7562#[gpui::test]
7563async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
7564 init_test(cx);
7565
7566 let fs = FakeFs::new(cx.executor());
7567 fs.insert_tree(
7568 path!("/root"),
7569 json!({
7570 "dir1": {
7571 "subdir1": {
7572 "nested1": {
7573 "file1.txt": "",
7574 "file2.txt": ""
7575 },
7576 },
7577 "subdir2": {
7578 "file4.txt": ""
7579 }
7580 },
7581 "dir2": {
7582 "single_file": {
7583 "file5.txt": ""
7584 }
7585 }
7586 }),
7587 )
7588 .await;
7589
7590 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7591 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7592 let workspace = window
7593 .read_with(cx, |mw, _| mw.workspace().clone())
7594 .unwrap();
7595 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7596
7597 // Test 1: Basic collapsing
7598 {
7599 let panel = workspace.update_in(cx, ProjectPanel::new);
7600 cx.run_until_parked();
7601
7602 toggle_expand_dir(&panel, "root/dir1", cx);
7603 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7604 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7605 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7606
7607 assert_eq!(
7608 visible_entries_as_strings(&panel, 0..20, cx),
7609 &[
7610 "v root",
7611 " v dir1",
7612 " v subdir1",
7613 " v nested1",
7614 " file1.txt",
7615 " file2.txt",
7616 " v subdir2 <== selected",
7617 " file4.txt",
7618 " > dir2",
7619 ],
7620 "Initial state with everything expanded"
7621 );
7622
7623 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7624 panel.update_in(cx, |panel, window, cx| {
7625 let project = panel.project.read(cx);
7626 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7627 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7628 panel.update_visible_entries(None, false, false, window, cx);
7629 });
7630 cx.run_until_parked();
7631
7632 assert_eq!(
7633 visible_entries_as_strings(&panel, 0..20, cx),
7634 &["v root", " > dir1", " > dir2",],
7635 "All subdirs under dir1 should be collapsed"
7636 );
7637 }
7638
7639 // Test 2: With auto-fold enabled
7640 {
7641 cx.update(|_, cx| {
7642 let settings = *ProjectPanelSettings::get_global(cx);
7643 ProjectPanelSettings::override_global(
7644 ProjectPanelSettings {
7645 auto_fold_dirs: true,
7646 ..settings
7647 },
7648 cx,
7649 );
7650 });
7651
7652 let panel = workspace.update_in(cx, ProjectPanel::new);
7653 cx.run_until_parked();
7654
7655 toggle_expand_dir(&panel, "root/dir1", cx);
7656 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7657 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7658
7659 assert_eq!(
7660 visible_entries_as_strings(&panel, 0..20, cx),
7661 &[
7662 "v root",
7663 " v dir1",
7664 " v subdir1/nested1 <== selected",
7665 " file1.txt",
7666 " file2.txt",
7667 " > subdir2",
7668 " > dir2/single_file",
7669 ],
7670 "Initial state with some dirs expanded"
7671 );
7672
7673 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7674 panel.update(cx, |panel, cx| {
7675 let project = panel.project.read(cx);
7676 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7677 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7678 });
7679
7680 toggle_expand_dir(&panel, "root/dir1", cx);
7681
7682 assert_eq!(
7683 visible_entries_as_strings(&panel, 0..20, cx),
7684 &[
7685 "v root",
7686 " v dir1 <== selected",
7687 " > subdir1/nested1",
7688 " > subdir2",
7689 " > dir2/single_file",
7690 ],
7691 "Subdirs should be collapsed and folded with auto-fold enabled"
7692 );
7693 }
7694
7695 // Test 3: With auto-fold disabled
7696 {
7697 cx.update(|_, cx| {
7698 let settings = *ProjectPanelSettings::get_global(cx);
7699 ProjectPanelSettings::override_global(
7700 ProjectPanelSettings {
7701 auto_fold_dirs: false,
7702 ..settings
7703 },
7704 cx,
7705 );
7706 });
7707
7708 let panel = workspace.update_in(cx, ProjectPanel::new);
7709 cx.run_until_parked();
7710
7711 toggle_expand_dir(&panel, "root/dir1", cx);
7712 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7713 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7714
7715 assert_eq!(
7716 visible_entries_as_strings(&panel, 0..20, cx),
7717 &[
7718 "v root",
7719 " v dir1",
7720 " v subdir1",
7721 " v nested1 <== selected",
7722 " file1.txt",
7723 " file2.txt",
7724 " > subdir2",
7725 " > dir2",
7726 ],
7727 "Initial state with some dirs expanded and auto-fold disabled"
7728 );
7729
7730 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7731 panel.update(cx, |panel, cx| {
7732 let project = panel.project.read(cx);
7733 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7734 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7735 });
7736
7737 toggle_expand_dir(&panel, "root/dir1", cx);
7738
7739 assert_eq!(
7740 visible_entries_as_strings(&panel, 0..20, cx),
7741 &[
7742 "v root",
7743 " v dir1 <== selected",
7744 " > subdir1",
7745 " > subdir2",
7746 " > dir2",
7747 ],
7748 "Subdirs should be collapsed but not folded with auto-fold disabled"
7749 );
7750 }
7751}
7752
7753#[gpui::test]
7754async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
7755 init_test(cx);
7756
7757 let fs = FakeFs::new(cx.executor());
7758 fs.insert_tree(
7759 path!("/root"),
7760 json!({
7761 "dir1": {
7762 "subdir1": {
7763 "nested1": {
7764 "file1.txt": "",
7765 "file2.txt": ""
7766 },
7767 },
7768 "subdir2": {
7769 "file3.txt": ""
7770 }
7771 },
7772 "dir2": {
7773 "file4.txt": ""
7774 }
7775 }),
7776 )
7777 .await;
7778
7779 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7780 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7781 let workspace = window
7782 .read_with(cx, |mw, _| mw.workspace().clone())
7783 .unwrap();
7784 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7785
7786 let panel = workspace.update_in(cx, ProjectPanel::new);
7787 cx.run_until_parked();
7788
7789 toggle_expand_dir(&panel, "root/dir1", cx);
7790 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7791 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7792 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7793 toggle_expand_dir(&panel, "root/dir2", cx);
7794
7795 assert_eq!(
7796 visible_entries_as_strings(&panel, 0..20, cx),
7797 &[
7798 "v root",
7799 " v dir1",
7800 " v subdir1",
7801 " v nested1",
7802 " file1.txt",
7803 " file2.txt",
7804 " v subdir2",
7805 " file3.txt",
7806 " v dir2 <== selected",
7807 " file4.txt",
7808 ],
7809 "Initial state with directories expanded"
7810 );
7811
7812 select_path(&panel, "root/dir1", cx);
7813 cx.run_until_parked();
7814
7815 panel.update_in(cx, |panel, window, cx| {
7816 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7817 });
7818 cx.run_until_parked();
7819
7820 assert_eq!(
7821 visible_entries_as_strings(&panel, 0..20, cx),
7822 &[
7823 "v root",
7824 " > dir1 <== selected",
7825 " v dir2",
7826 " file4.txt",
7827 ],
7828 "dir1 and all its children should be collapsed, dir2 should remain expanded"
7829 );
7830
7831 toggle_expand_dir(&panel, "root/dir1", cx);
7832 cx.run_until_parked();
7833
7834 assert_eq!(
7835 visible_entries_as_strings(&panel, 0..20, cx),
7836 &[
7837 "v root",
7838 " v dir1 <== selected",
7839 " > subdir1",
7840 " > subdir2",
7841 " v dir2",
7842 " file4.txt",
7843 ],
7844 "After re-expanding dir1, its children should still be collapsed"
7845 );
7846}
7847
7848#[gpui::test]
7849async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
7850 init_test(cx);
7851
7852 let fs = FakeFs::new(cx.executor());
7853 fs.insert_tree(
7854 path!("/root"),
7855 json!({
7856 "dir1": {
7857 "subdir1": {
7858 "file1.txt": ""
7859 },
7860 "file2.txt": ""
7861 },
7862 "dir2": {
7863 "file3.txt": ""
7864 }
7865 }),
7866 )
7867 .await;
7868
7869 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7870 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7871 let workspace = window
7872 .read_with(cx, |mw, _| mw.workspace().clone())
7873 .unwrap();
7874 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7875
7876 let panel = workspace.update_in(cx, ProjectPanel::new);
7877 cx.run_until_parked();
7878
7879 toggle_expand_dir(&panel, "root/dir1", cx);
7880 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7881 toggle_expand_dir(&panel, "root/dir2", cx);
7882
7883 assert_eq!(
7884 visible_entries_as_strings(&panel, 0..20, cx),
7885 &[
7886 "v root",
7887 " v dir1",
7888 " v subdir1",
7889 " file1.txt",
7890 " file2.txt",
7891 " v dir2 <== selected",
7892 " file3.txt",
7893 ],
7894 "Initial state with directories expanded"
7895 );
7896
7897 // Select the root and collapse it and its children
7898 select_path(&panel, "root", cx);
7899 cx.run_until_parked();
7900
7901 panel.update_in(cx, |panel, window, cx| {
7902 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7903 });
7904 cx.run_until_parked();
7905
7906 // The root and all its children should be collapsed
7907 assert_eq!(
7908 visible_entries_as_strings(&panel, 0..20, cx),
7909 &["> root <== selected"],
7910 "Root and all children should be collapsed"
7911 );
7912
7913 // Re-expand root and dir1, verify children were recursively collapsed
7914 toggle_expand_dir(&panel, "root", cx);
7915 toggle_expand_dir(&panel, "root/dir1", cx);
7916 cx.run_until_parked();
7917
7918 assert_eq!(
7919 visible_entries_as_strings(&panel, 0..20, cx),
7920 &[
7921 "v root",
7922 " v dir1 <== selected",
7923 " > subdir1",
7924 " file2.txt",
7925 " > dir2",
7926 ],
7927 "After re-expanding root and dir1, subdir1 should still be collapsed"
7928 );
7929}
7930
7931#[gpui::test]
7932async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7933 init_test(cx);
7934
7935 let fs = FakeFs::new(cx.executor());
7936 fs.insert_tree(
7937 path!("/root1"),
7938 json!({
7939 "dir1": {
7940 "subdir1": {
7941 "file1.txt": ""
7942 },
7943 "file2.txt": ""
7944 }
7945 }),
7946 )
7947 .await;
7948 fs.insert_tree(
7949 path!("/root2"),
7950 json!({
7951 "dir2": {
7952 "file3.txt": ""
7953 },
7954 "file4.txt": ""
7955 }),
7956 )
7957 .await;
7958
7959 let project = Project::test(
7960 fs.clone(),
7961 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7962 cx,
7963 )
7964 .await;
7965 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7966 let workspace = window
7967 .read_with(cx, |mw, _| mw.workspace().clone())
7968 .unwrap();
7969 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7970
7971 let panel = workspace.update_in(cx, ProjectPanel::new);
7972 cx.run_until_parked();
7973
7974 toggle_expand_dir(&panel, "root1/dir1", cx);
7975 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7976 toggle_expand_dir(&panel, "root2/dir2", cx);
7977
7978 assert_eq!(
7979 visible_entries_as_strings(&panel, 0..20, cx),
7980 &[
7981 "v root1",
7982 " v dir1",
7983 " v subdir1",
7984 " file1.txt",
7985 " file2.txt",
7986 "v root2",
7987 " v dir2 <== selected",
7988 " file3.txt",
7989 " file4.txt",
7990 ],
7991 "Initial state with directories expanded across worktrees"
7992 );
7993
7994 // Select root1 and collapse it and its children.
7995 // In a multi-worktree project, this should only collapse the selected worktree,
7996 // leaving other worktrees unaffected.
7997 select_path(&panel, "root1", cx);
7998 cx.run_until_parked();
7999
8000 panel.update_in(cx, |panel, window, cx| {
8001 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
8002 });
8003 cx.run_until_parked();
8004
8005 assert_eq!(
8006 visible_entries_as_strings(&panel, 0..20, cx),
8007 &[
8008 "> root1 <== selected",
8009 "v root2",
8010 " v dir2",
8011 " file3.txt",
8012 " file4.txt",
8013 ],
8014 "Only root1 should be collapsed, root2 should remain expanded"
8015 );
8016
8017 // Re-expand root1 and verify its children were recursively collapsed
8018 toggle_expand_dir(&panel, "root1", cx);
8019
8020 assert_eq!(
8021 visible_entries_as_strings(&panel, 0..20, cx),
8022 &[
8023 "v root1 <== selected",
8024 " > dir1",
8025 "v root2",
8026 " v dir2",
8027 " file3.txt",
8028 " file4.txt",
8029 ],
8030 "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
8031 );
8032}
8033
8034#[gpui::test]
8035async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
8036 init_test(cx);
8037
8038 let fs = FakeFs::new(cx.executor());
8039 fs.insert_tree(
8040 path!("/root1"),
8041 json!({
8042 "dir1": {
8043 "subdir1": {
8044 "file1.txt": ""
8045 },
8046 "file2.txt": ""
8047 }
8048 }),
8049 )
8050 .await;
8051 fs.insert_tree(
8052 path!("/root2"),
8053 json!({
8054 "dir2": {
8055 "subdir2": {
8056 "file3.txt": ""
8057 },
8058 "file4.txt": ""
8059 }
8060 }),
8061 )
8062 .await;
8063
8064 let project = Project::test(
8065 fs.clone(),
8066 [path!("/root1").as_ref(), path!("/root2").as_ref()],
8067 cx,
8068 )
8069 .await;
8070 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8071 let workspace = window
8072 .read_with(cx, |mw, _| mw.workspace().clone())
8073 .unwrap();
8074 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8075
8076 let panel = workspace.update_in(cx, ProjectPanel::new);
8077 cx.run_until_parked();
8078
8079 toggle_expand_dir(&panel, "root1/dir1", cx);
8080 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
8081 toggle_expand_dir(&panel, "root2/dir2", cx);
8082 toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
8083
8084 assert_eq!(
8085 visible_entries_as_strings(&panel, 0..20, cx),
8086 &[
8087 "v root1",
8088 " v dir1",
8089 " v subdir1",
8090 " file1.txt",
8091 " file2.txt",
8092 "v root2",
8093 " v dir2",
8094 " v subdir2 <== selected",
8095 " file3.txt",
8096 " file4.txt",
8097 ],
8098 "Initial state with directories expanded across worktrees"
8099 );
8100
8101 // Select dir1 in root1 and collapse it
8102 select_path(&panel, "root1/dir1", cx);
8103 cx.run_until_parked();
8104
8105 panel.update_in(cx, |panel, window, cx| {
8106 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
8107 });
8108 cx.run_until_parked();
8109
8110 assert_eq!(
8111 visible_entries_as_strings(&panel, 0..20, cx),
8112 &[
8113 "v root1",
8114 " > dir1 <== selected",
8115 "v root2",
8116 " v dir2",
8117 " v subdir2",
8118 " file3.txt",
8119 " file4.txt",
8120 ],
8121 "Only dir1 should be collapsed, root2 should be completely unaffected"
8122 );
8123
8124 // Re-expand dir1 and verify subdir1 was recursively collapsed
8125 toggle_expand_dir(&panel, "root1/dir1", cx);
8126
8127 assert_eq!(
8128 visible_entries_as_strings(&panel, 0..20, cx),
8129 &[
8130 "v root1",
8131 " v dir1 <== selected",
8132 " > subdir1",
8133 " file2.txt",
8134 "v root2",
8135 " v dir2",
8136 " v subdir2",
8137 " file3.txt",
8138 " file4.txt",
8139 ],
8140 "After re-expanding dir1, subdir1 should still be collapsed"
8141 );
8142}
8143
8144#[gpui::test]
8145async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
8146 init_test(cx);
8147
8148 let fs = FakeFs::new(cx.executor());
8149 fs.insert_tree(
8150 path!("/root"),
8151 json!({
8152 "dir1": {
8153 "subdir1": {
8154 "file1.txt": ""
8155 },
8156 "file2.txt": ""
8157 },
8158 "dir2": {
8159 "file3.txt": ""
8160 }
8161 }),
8162 )
8163 .await;
8164
8165 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8166 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8167 let workspace = window
8168 .read_with(cx, |mw, _| mw.workspace().clone())
8169 .unwrap();
8170 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8171
8172 let panel = workspace.update_in(cx, ProjectPanel::new);
8173 cx.run_until_parked();
8174
8175 toggle_expand_dir(&panel, "root/dir1", cx);
8176 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8177 toggle_expand_dir(&panel, "root/dir2", cx);
8178
8179 assert_eq!(
8180 visible_entries_as_strings(&panel, 0..20, cx),
8181 &[
8182 "v root",
8183 " v dir1",
8184 " v subdir1",
8185 " file1.txt",
8186 " file2.txt",
8187 " v dir2 <== selected",
8188 " file3.txt",
8189 ],
8190 "Initial state with directories expanded"
8191 );
8192
8193 select_path(&panel, "root", cx);
8194 cx.run_until_parked();
8195
8196 panel.update_in(cx, |panel, window, cx| {
8197 panel.collapse_all_for_root(window, cx);
8198 });
8199 cx.run_until_parked();
8200
8201 assert_eq!(
8202 visible_entries_as_strings(&panel, 0..20, cx),
8203 &["v root <== selected", " > dir1", " > dir2"],
8204 "Root should remain expanded but all children should be collapsed"
8205 );
8206
8207 toggle_expand_dir(&panel, "root/dir1", cx);
8208 cx.run_until_parked();
8209
8210 assert_eq!(
8211 visible_entries_as_strings(&panel, 0..20, cx),
8212 &[
8213 "v root",
8214 " v dir1 <== selected",
8215 " > subdir1",
8216 " file2.txt",
8217 " > dir2",
8218 ],
8219 "After re-expanding dir1, subdir1 should still be collapsed"
8220 );
8221}
8222
8223#[gpui::test]
8224async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
8225 init_test(cx);
8226
8227 let fs = FakeFs::new(cx.executor());
8228 fs.insert_tree(
8229 path!("/root1"),
8230 json!({
8231 "dir1": {
8232 "subdir1": {
8233 "file1.txt": ""
8234 },
8235 "file2.txt": ""
8236 }
8237 }),
8238 )
8239 .await;
8240 fs.insert_tree(
8241 path!("/root2"),
8242 json!({
8243 "dir2": {
8244 "file3.txt": ""
8245 },
8246 "file4.txt": ""
8247 }),
8248 )
8249 .await;
8250
8251 let project = Project::test(
8252 fs.clone(),
8253 [path!("/root1").as_ref(), path!("/root2").as_ref()],
8254 cx,
8255 )
8256 .await;
8257 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8258 let workspace = window
8259 .read_with(cx, |mw, _| mw.workspace().clone())
8260 .unwrap();
8261 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8262
8263 let panel = workspace.update_in(cx, ProjectPanel::new);
8264 cx.run_until_parked();
8265
8266 toggle_expand_dir(&panel, "root1/dir1", cx);
8267 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
8268 toggle_expand_dir(&panel, "root2/dir2", cx);
8269
8270 assert_eq!(
8271 visible_entries_as_strings(&panel, 0..20, cx),
8272 &[
8273 "v root1",
8274 " v dir1",
8275 " v subdir1",
8276 " file1.txt",
8277 " file2.txt",
8278 "v root2",
8279 " v dir2 <== selected",
8280 " file3.txt",
8281 " file4.txt",
8282 ],
8283 "Initial state with directories expanded across worktrees"
8284 );
8285
8286 select_path(&panel, "root1", cx);
8287 cx.run_until_parked();
8288
8289 panel.update_in(cx, |panel, window, cx| {
8290 panel.collapse_all_for_root(window, cx);
8291 });
8292 cx.run_until_parked();
8293
8294 assert_eq!(
8295 visible_entries_as_strings(&panel, 0..20, cx),
8296 &[
8297 "> root1 <== selected",
8298 "v root2",
8299 " v dir2",
8300 " file3.txt",
8301 " file4.txt",
8302 ],
8303 "With multiple worktrees, root1 should collapse completely (including itself)"
8304 );
8305}
8306
8307#[gpui::test]
8308async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
8309 init_test(cx);
8310
8311 let fs = FakeFs::new(cx.executor());
8312 fs.insert_tree(
8313 path!("/root"),
8314 json!({
8315 "dir1": {
8316 "subdir1": {
8317 "file1.txt": ""
8318 },
8319 },
8320 "dir2": {
8321 "file2.txt": ""
8322 }
8323 }),
8324 )
8325 .await;
8326
8327 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8328 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8329 let workspace = window
8330 .read_with(cx, |mw, _| mw.workspace().clone())
8331 .unwrap();
8332 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8333
8334 let panel = workspace.update_in(cx, ProjectPanel::new);
8335 cx.run_until_parked();
8336
8337 toggle_expand_dir(&panel, "root/dir1", cx);
8338 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8339 toggle_expand_dir(&panel, "root/dir2", cx);
8340
8341 assert_eq!(
8342 visible_entries_as_strings(&panel, 0..20, cx),
8343 &[
8344 "v root",
8345 " v dir1",
8346 " v subdir1",
8347 " file1.txt",
8348 " v dir2 <== selected",
8349 " file2.txt",
8350 ],
8351 "Initial state with directories expanded"
8352 );
8353
8354 select_path(&panel, "root/dir1", cx);
8355 cx.run_until_parked();
8356
8357 panel.update_in(cx, |panel, window, cx| {
8358 panel.collapse_all_for_root(window, cx);
8359 });
8360 cx.run_until_parked();
8361
8362 assert_eq!(
8363 visible_entries_as_strings(&panel, 0..20, cx),
8364 &[
8365 "v root",
8366 " v dir1 <== selected",
8367 " v subdir1",
8368 " file1.txt",
8369 " v dir2",
8370 " file2.txt",
8371 ],
8372 "collapse_all_for_root should be a no-op when called on a non-root directory"
8373 );
8374}
8375
8376#[gpui::test]
8377async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
8378 init_test(cx);
8379
8380 let fs = FakeFs::new(cx.executor());
8381 fs.insert_tree(
8382 path!("/root"),
8383 json!({
8384 "dir1": {
8385 "file1.txt": "",
8386 },
8387 }),
8388 )
8389 .await;
8390
8391 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8392 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8393 let workspace = window
8394 .read_with(cx, |mw, _| mw.workspace().clone())
8395 .unwrap();
8396 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8397
8398 let panel = workspace.update_in(cx, |workspace, window, cx| {
8399 let panel = ProjectPanel::new(workspace, window, cx);
8400 workspace.add_panel(panel.clone(), window, cx);
8401 panel
8402 });
8403 cx.run_until_parked();
8404
8405 #[rustfmt::skip]
8406 assert_eq!(
8407 visible_entries_as_strings(&panel, 0..20, cx),
8408 &[
8409 "v root",
8410 " > dir1",
8411 ],
8412 "Initial state with nothing selected"
8413 );
8414
8415 panel.update_in(cx, |panel, window, cx| {
8416 panel.new_file(&NewFile, window, cx);
8417 });
8418 cx.run_until_parked();
8419 panel.update_in(cx, |panel, window, cx| {
8420 assert!(panel.filename_editor.read(cx).is_focused(window));
8421 });
8422 panel
8423 .update_in(cx, |panel, window, cx| {
8424 panel.filename_editor.update(cx, |editor, cx| {
8425 editor.set_text("hello_from_no_selections", window, cx)
8426 });
8427 panel.confirm_edit(true, window, cx).unwrap()
8428 })
8429 .await
8430 .unwrap();
8431 cx.run_until_parked();
8432 #[rustfmt::skip]
8433 assert_eq!(
8434 visible_entries_as_strings(&panel, 0..20, cx),
8435 &[
8436 "v root",
8437 " > dir1",
8438 " hello_from_no_selections <== selected <== marked",
8439 ],
8440 "A new file is created under the root directory"
8441 );
8442}
8443
8444#[gpui::test]
8445async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
8446 init_test(cx);
8447
8448 let fs = FakeFs::new(cx.executor());
8449 fs.insert_tree(
8450 path!("/root"),
8451 json!({
8452 "existing_dir": {
8453 "existing_file.txt": "",
8454 },
8455 "existing_file.txt": "",
8456 }),
8457 )
8458 .await;
8459
8460 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8461 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8462 let workspace = window
8463 .read_with(cx, |mw, _| mw.workspace().clone())
8464 .unwrap();
8465 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8466
8467 cx.update(|_, cx| {
8468 let settings = *ProjectPanelSettings::get_global(cx);
8469 ProjectPanelSettings::override_global(
8470 ProjectPanelSettings {
8471 hide_root: true,
8472 ..settings
8473 },
8474 cx,
8475 );
8476 });
8477
8478 let panel = workspace.update_in(cx, |workspace, window, cx| {
8479 let panel = ProjectPanel::new(workspace, window, cx);
8480 workspace.add_panel(panel.clone(), window, cx);
8481 panel
8482 });
8483 cx.run_until_parked();
8484
8485 #[rustfmt::skip]
8486 assert_eq!(
8487 visible_entries_as_strings(&panel, 0..20, cx),
8488 &[
8489 "> existing_dir",
8490 " existing_file.txt",
8491 ],
8492 "Initial state with hide_root=true, root should be hidden and nothing selected"
8493 );
8494
8495 panel.update(cx, |panel, _| {
8496 assert!(
8497 panel.selection.is_none(),
8498 "Should have no selection initially"
8499 );
8500 });
8501
8502 // Test 1: Create new file when no entry is selected
8503 panel.update_in(cx, |panel, window, cx| {
8504 panel.new_file(&NewFile, window, cx);
8505 });
8506 cx.run_until_parked();
8507 panel.update_in(cx, |panel, window, cx| {
8508 assert!(panel.filename_editor.read(cx).is_focused(window));
8509 });
8510 cx.run_until_parked();
8511 #[rustfmt::skip]
8512 assert_eq!(
8513 visible_entries_as_strings(&panel, 0..20, cx),
8514 &[
8515 "> existing_dir",
8516 " [EDITOR: ''] <== selected",
8517 " existing_file.txt",
8518 ],
8519 "Editor should appear at root level when hide_root=true and no selection"
8520 );
8521
8522 let confirm = panel.update_in(cx, |panel, window, cx| {
8523 panel.filename_editor.update(cx, |editor, cx| {
8524 editor.set_text("new_file_at_root.txt", window, cx)
8525 });
8526 panel.confirm_edit(true, window, cx).unwrap()
8527 });
8528 confirm.await.unwrap();
8529 cx.run_until_parked();
8530
8531 #[rustfmt::skip]
8532 assert_eq!(
8533 visible_entries_as_strings(&panel, 0..20, cx),
8534 &[
8535 "> existing_dir",
8536 " existing_file.txt",
8537 " new_file_at_root.txt <== selected <== marked",
8538 ],
8539 "New file should be created at root level and visible without root prefix"
8540 );
8541
8542 assert!(
8543 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
8544 "File should be created in the actual root directory"
8545 );
8546
8547 // Test 2: Create new directory when no entry is selected
8548 panel.update(cx, |panel, _| {
8549 panel.selection = None;
8550 });
8551
8552 panel.update_in(cx, |panel, window, cx| {
8553 panel.new_directory(&NewDirectory, window, cx);
8554 });
8555 cx.run_until_parked();
8556
8557 panel.update_in(cx, |panel, window, cx| {
8558 assert!(panel.filename_editor.read(cx).is_focused(window));
8559 });
8560
8561 #[rustfmt::skip]
8562 assert_eq!(
8563 visible_entries_as_strings(&panel, 0..20, cx),
8564 &[
8565 "> [EDITOR: ''] <== selected",
8566 "> existing_dir",
8567 " existing_file.txt",
8568 " new_file_at_root.txt",
8569 ],
8570 "Directory editor should appear at root level when hide_root=true and no selection"
8571 );
8572
8573 let confirm = panel.update_in(cx, |panel, window, cx| {
8574 panel.filename_editor.update(cx, |editor, cx| {
8575 editor.set_text("new_dir_at_root", window, cx)
8576 });
8577 panel.confirm_edit(true, window, cx).unwrap()
8578 });
8579 confirm.await.unwrap();
8580 cx.run_until_parked();
8581
8582 #[rustfmt::skip]
8583 assert_eq!(
8584 visible_entries_as_strings(&panel, 0..20, cx),
8585 &[
8586 "> existing_dir",
8587 "v new_dir_at_root <== selected",
8588 " existing_file.txt",
8589 " new_file_at_root.txt",
8590 ],
8591 "New directory should be created at root level and visible without root prefix"
8592 );
8593
8594 assert!(
8595 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
8596 "Directory should be created in the actual root directory"
8597 );
8598}
8599
8600#[cfg(windows)]
8601#[gpui::test]
8602async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
8603 init_test(cx);
8604
8605 let fs = FakeFs::new(cx.executor());
8606 fs.insert_tree(
8607 path!("/root"),
8608 json!({
8609 "dir1": {
8610 "file1.txt": "",
8611 },
8612 }),
8613 )
8614 .await;
8615
8616 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8617 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8618 let workspace = window
8619 .read_with(cx, |mw, _| mw.workspace().clone())
8620 .unwrap();
8621 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8622
8623 let panel = workspace.update_in(cx, |workspace, window, cx| {
8624 let panel = ProjectPanel::new(workspace, window, cx);
8625 workspace.add_panel(panel.clone(), window, cx);
8626 panel
8627 });
8628 cx.run_until_parked();
8629
8630 #[rustfmt::skip]
8631 assert_eq!(
8632 visible_entries_as_strings(&panel, 0..20, cx),
8633 &[
8634 "v root",
8635 " > dir1",
8636 ],
8637 "Initial state with nothing selected"
8638 );
8639
8640 panel.update_in(cx, |panel, window, cx| {
8641 panel.new_file(&NewFile, window, cx);
8642 });
8643 cx.run_until_parked();
8644 panel.update_in(cx, |panel, window, cx| {
8645 assert!(panel.filename_editor.read(cx).is_focused(window));
8646 });
8647 panel
8648 .update_in(cx, |panel, window, cx| {
8649 panel
8650 .filename_editor
8651 .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
8652 panel.confirm_edit(true, window, cx).unwrap()
8653 })
8654 .await
8655 .unwrap();
8656 cx.run_until_parked();
8657 #[rustfmt::skip]
8658 assert_eq!(
8659 visible_entries_as_strings(&panel, 0..20, cx),
8660 &[
8661 "v root",
8662 " > dir1",
8663 " foo <== selected <== marked",
8664 ],
8665 "A new file is created under the root directory without the trailing dot"
8666 );
8667}
8668
8669#[gpui::test]
8670async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
8671 init_test(cx);
8672
8673 let fs = FakeFs::new(cx.executor());
8674 fs.insert_tree(
8675 "/root",
8676 json!({
8677 "dir1": {
8678 "file1.txt": "",
8679 "dir2": {
8680 "file2.txt": ""
8681 }
8682 },
8683 "file3.txt": ""
8684 }),
8685 )
8686 .await;
8687
8688 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8689 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8690 let workspace = window
8691 .read_with(cx, |mw, _| mw.workspace().clone())
8692 .unwrap();
8693 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8694 let panel = workspace.update_in(cx, ProjectPanel::new);
8695 cx.run_until_parked();
8696
8697 panel.update(cx, |panel, cx| {
8698 let project = panel.project.read(cx);
8699 let worktree = project.visible_worktrees(cx).next().unwrap();
8700 let worktree = worktree.read(cx);
8701
8702 // Test 1: Target is a directory, should highlight the directory itself
8703 let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
8704 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
8705 assert_eq!(
8706 result,
8707 Some(dir_entry.id),
8708 "Should highlight directory itself"
8709 );
8710
8711 // Test 2: Target is nested file, should highlight immediate parent
8712 let nested_file = worktree
8713 .entry_for_path(rel_path("dir1/dir2/file2.txt"))
8714 .unwrap();
8715 let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
8716 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
8717 assert_eq!(
8718 result,
8719 Some(nested_parent.id),
8720 "Should highlight immediate parent"
8721 );
8722
8723 // Test 3: Target is root level file, should highlight root
8724 let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
8725 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
8726 assert_eq!(
8727 result,
8728 Some(worktree.root_entry().unwrap().id),
8729 "Root level file should return None"
8730 );
8731
8732 // Test 4: Target is root itself, should highlight root
8733 let root_entry = worktree.root_entry().unwrap();
8734 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
8735 assert_eq!(
8736 result,
8737 Some(root_entry.id),
8738 "Root level file should return None"
8739 );
8740 });
8741}
8742
8743#[gpui::test]
8744async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
8745 init_test(cx);
8746
8747 let fs = FakeFs::new(cx.executor());
8748 fs.insert_tree(
8749 "/root",
8750 json!({
8751 "parent_dir": {
8752 "child_file.txt": "",
8753 "sibling_file.txt": "",
8754 "child_dir": {
8755 "nested_file.txt": ""
8756 }
8757 },
8758 "other_dir": {
8759 "other_file.txt": ""
8760 }
8761 }),
8762 )
8763 .await;
8764
8765 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8766 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8767 let workspace = window
8768 .read_with(cx, |mw, _| mw.workspace().clone())
8769 .unwrap();
8770 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8771 let panel = workspace.update_in(cx, ProjectPanel::new);
8772 cx.run_until_parked();
8773
8774 panel.update(cx, |panel, cx| {
8775 let project = panel.project.read(cx);
8776 let worktree = project.visible_worktrees(cx).next().unwrap();
8777 let worktree_id = worktree.read(cx).id();
8778 let worktree = worktree.read(cx);
8779
8780 let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
8781 let child_file = worktree
8782 .entry_for_path(rel_path("parent_dir/child_file.txt"))
8783 .unwrap();
8784 let sibling_file = worktree
8785 .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
8786 .unwrap();
8787 let child_dir = worktree
8788 .entry_for_path(rel_path("parent_dir/child_dir"))
8789 .unwrap();
8790 let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
8791 let other_file = worktree
8792 .entry_for_path(rel_path("other_dir/other_file.txt"))
8793 .unwrap();
8794
8795 // Test 1: Single item drag, don't highlight parent directory
8796 let dragged_selection = DraggedSelection {
8797 active_selection: SelectedEntry {
8798 worktree_id,
8799 entry_id: child_file.id,
8800 },
8801 marked_selections: Arc::new([SelectedEntry {
8802 worktree_id,
8803 entry_id: child_file.id,
8804 }]),
8805 };
8806 let result =
8807 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
8808 assert_eq!(result, None, "Should not highlight parent of dragged item");
8809
8810 // Test 2: Single item drag, don't highlight sibling files
8811 let result = panel.highlight_entry_for_selection_drag(
8812 sibling_file,
8813 worktree,
8814 &dragged_selection,
8815 cx,
8816 );
8817 assert_eq!(result, None, "Should not highlight sibling files");
8818
8819 // Test 3: Single item drag, highlight unrelated directory
8820 let result =
8821 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
8822 assert_eq!(
8823 result,
8824 Some(other_dir.id),
8825 "Should highlight unrelated directory"
8826 );
8827
8828 // Test 4: Single item drag, highlight sibling directory
8829 let result =
8830 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
8831 assert_eq!(
8832 result,
8833 Some(child_dir.id),
8834 "Should highlight sibling directory"
8835 );
8836
8837 // Test 5: Multiple items drag, highlight parent directory
8838 let dragged_selection = DraggedSelection {
8839 active_selection: SelectedEntry {
8840 worktree_id,
8841 entry_id: child_file.id,
8842 },
8843 marked_selections: Arc::new([
8844 SelectedEntry {
8845 worktree_id,
8846 entry_id: child_file.id,
8847 },
8848 SelectedEntry {
8849 worktree_id,
8850 entry_id: sibling_file.id,
8851 },
8852 ]),
8853 };
8854 let result =
8855 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
8856 assert_eq!(
8857 result,
8858 Some(parent_dir.id),
8859 "Should highlight parent with multiple items"
8860 );
8861
8862 // Test 6: Target is file in different directory, highlight parent
8863 let result =
8864 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
8865 assert_eq!(
8866 result,
8867 Some(other_dir.id),
8868 "Should highlight parent of target file"
8869 );
8870
8871 // Test 7: Target is directory, always highlight
8872 let result =
8873 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
8874 assert_eq!(
8875 result,
8876 Some(child_dir.id),
8877 "Should always highlight directories"
8878 );
8879 });
8880}
8881
8882#[gpui::test]
8883async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
8884 init_test(cx);
8885
8886 let fs = FakeFs::new(cx.executor());
8887 fs.insert_tree(
8888 "/root1",
8889 json!({
8890 "src": {
8891 "main.rs": "",
8892 "lib.rs": ""
8893 }
8894 }),
8895 )
8896 .await;
8897 fs.insert_tree(
8898 "/root2",
8899 json!({
8900 "src": {
8901 "main.rs": "",
8902 "test.rs": ""
8903 }
8904 }),
8905 )
8906 .await;
8907
8908 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8909 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8910 let workspace = window
8911 .read_with(cx, |mw, _| mw.workspace().clone())
8912 .unwrap();
8913 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8914 let panel = workspace.update_in(cx, ProjectPanel::new);
8915 cx.run_until_parked();
8916
8917 panel.update(cx, |panel, cx| {
8918 let project = panel.project.read(cx);
8919 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8920
8921 let worktree_a = &worktrees[0];
8922 let main_rs_from_a = worktree_a
8923 .read(cx)
8924 .entry_for_path(rel_path("src/main.rs"))
8925 .unwrap();
8926
8927 let worktree_b = &worktrees[1];
8928 let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
8929 let main_rs_from_b = worktree_b
8930 .read(cx)
8931 .entry_for_path(rel_path("src/main.rs"))
8932 .unwrap();
8933
8934 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
8935 let dragged_selection = DraggedSelection {
8936 active_selection: SelectedEntry {
8937 worktree_id: worktree_a.read(cx).id(),
8938 entry_id: main_rs_from_a.id,
8939 },
8940 marked_selections: Arc::new([SelectedEntry {
8941 worktree_id: worktree_a.read(cx).id(),
8942 entry_id: main_rs_from_a.id,
8943 }]),
8944 };
8945
8946 let result = panel.highlight_entry_for_selection_drag(
8947 src_dir_from_b,
8948 worktree_b.read(cx),
8949 &dragged_selection,
8950 cx,
8951 );
8952 assert_eq!(
8953 result,
8954 Some(src_dir_from_b.id),
8955 "Should highlight target directory from different worktree even with same relative path"
8956 );
8957
8958 // Test dragging file from worktree A onto file with same relative path in worktree B
8959 let result = panel.highlight_entry_for_selection_drag(
8960 main_rs_from_b,
8961 worktree_b.read(cx),
8962 &dragged_selection,
8963 cx,
8964 );
8965 assert_eq!(
8966 result,
8967 Some(src_dir_from_b.id),
8968 "Should highlight parent of target file from different worktree"
8969 );
8970 });
8971}
8972
8973#[gpui::test]
8974async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
8975 init_test(cx);
8976
8977 let fs = FakeFs::new(cx.executor());
8978 fs.insert_tree(
8979 "/root1",
8980 json!({
8981 "parent_dir": {
8982 "child_file.txt": "",
8983 "nested_dir": {
8984 "nested_file.txt": ""
8985 }
8986 },
8987 "root_file.txt": ""
8988 }),
8989 )
8990 .await;
8991
8992 fs.insert_tree(
8993 "/root2",
8994 json!({
8995 "other_dir": {
8996 "other_file.txt": ""
8997 }
8998 }),
8999 )
9000 .await;
9001
9002 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9003 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9004 let workspace = window
9005 .read_with(cx, |mw, _| mw.workspace().clone())
9006 .unwrap();
9007 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9008 let panel = workspace.update_in(cx, ProjectPanel::new);
9009 cx.run_until_parked();
9010
9011 panel.update(cx, |panel, cx| {
9012 let project = panel.project.read(cx);
9013 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
9014 let worktree1 = worktrees[0].read(cx);
9015 let worktree2 = worktrees[1].read(cx);
9016 let worktree1_id = worktree1.id();
9017 let _worktree2_id = worktree2.id();
9018
9019 let root1_entry = worktree1.root_entry().unwrap();
9020 let root2_entry = worktree2.root_entry().unwrap();
9021 let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
9022 let child_file = worktree1
9023 .entry_for_path(rel_path("parent_dir/child_file.txt"))
9024 .unwrap();
9025 let nested_file = worktree1
9026 .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
9027 .unwrap();
9028 let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
9029
9030 // Test 1: Multiple entries - should always highlight background
9031 let multiple_dragged_selection = DraggedSelection {
9032 active_selection: SelectedEntry {
9033 worktree_id: worktree1_id,
9034 entry_id: child_file.id,
9035 },
9036 marked_selections: Arc::new([
9037 SelectedEntry {
9038 worktree_id: worktree1_id,
9039 entry_id: child_file.id,
9040 },
9041 SelectedEntry {
9042 worktree_id: worktree1_id,
9043 entry_id: nested_file.id,
9044 },
9045 ]),
9046 };
9047
9048 let result = panel.should_highlight_background_for_selection_drag(
9049 &multiple_dragged_selection,
9050 root1_entry.id,
9051 cx,
9052 );
9053 assert!(result, "Should highlight background for multiple entries");
9054
9055 // Test 2: Single entry with non-empty parent path - should highlight background
9056 let nested_dragged_selection = DraggedSelection {
9057 active_selection: SelectedEntry {
9058 worktree_id: worktree1_id,
9059 entry_id: nested_file.id,
9060 },
9061 marked_selections: Arc::new([SelectedEntry {
9062 worktree_id: worktree1_id,
9063 entry_id: nested_file.id,
9064 }]),
9065 };
9066
9067 let result = panel.should_highlight_background_for_selection_drag(
9068 &nested_dragged_selection,
9069 root1_entry.id,
9070 cx,
9071 );
9072 assert!(result, "Should highlight background for nested file");
9073
9074 // Test 3: Single entry at root level, same worktree - should NOT highlight background
9075 let root_file_dragged_selection = DraggedSelection {
9076 active_selection: SelectedEntry {
9077 worktree_id: worktree1_id,
9078 entry_id: root_file.id,
9079 },
9080 marked_selections: Arc::new([SelectedEntry {
9081 worktree_id: worktree1_id,
9082 entry_id: root_file.id,
9083 }]),
9084 };
9085
9086 let result = panel.should_highlight_background_for_selection_drag(
9087 &root_file_dragged_selection,
9088 root1_entry.id,
9089 cx,
9090 );
9091 assert!(
9092 !result,
9093 "Should NOT highlight background for root file in same worktree"
9094 );
9095
9096 // Test 4: Single entry at root level, different worktree - should highlight background
9097 let result = panel.should_highlight_background_for_selection_drag(
9098 &root_file_dragged_selection,
9099 root2_entry.id,
9100 cx,
9101 );
9102 assert!(
9103 result,
9104 "Should highlight background for root file from different worktree"
9105 );
9106
9107 // Test 5: Single entry in subdirectory - should highlight background
9108 let child_file_dragged_selection = DraggedSelection {
9109 active_selection: SelectedEntry {
9110 worktree_id: worktree1_id,
9111 entry_id: child_file.id,
9112 },
9113 marked_selections: Arc::new([SelectedEntry {
9114 worktree_id: worktree1_id,
9115 entry_id: child_file.id,
9116 }]),
9117 };
9118
9119 let result = panel.should_highlight_background_for_selection_drag(
9120 &child_file_dragged_selection,
9121 root1_entry.id,
9122 cx,
9123 );
9124 assert!(
9125 result,
9126 "Should highlight background for file with non-empty parent path"
9127 );
9128 });
9129}
9130
9131#[gpui::test]
9132async fn test_hide_root(cx: &mut gpui::TestAppContext) {
9133 init_test(cx);
9134
9135 let fs = FakeFs::new(cx.executor());
9136 fs.insert_tree(
9137 "/root1",
9138 json!({
9139 "dir1": {
9140 "file1.txt": "content",
9141 "file2.txt": "content",
9142 },
9143 "dir2": {
9144 "file3.txt": "content",
9145 },
9146 "file4.txt": "content",
9147 }),
9148 )
9149 .await;
9150
9151 fs.insert_tree(
9152 "/root2",
9153 json!({
9154 "dir3": {
9155 "file5.txt": "content",
9156 },
9157 "file6.txt": "content",
9158 }),
9159 )
9160 .await;
9161
9162 // Test 1: Single worktree with hide_root = false
9163 {
9164 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9165 let window =
9166 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9167 let workspace = window
9168 .read_with(cx, |mw, _| mw.workspace().clone())
9169 .unwrap();
9170 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9171
9172 cx.update(|_, cx| {
9173 let settings = *ProjectPanelSettings::get_global(cx);
9174 ProjectPanelSettings::override_global(
9175 ProjectPanelSettings {
9176 hide_root: false,
9177 ..settings
9178 },
9179 cx,
9180 );
9181 });
9182
9183 let panel = workspace.update_in(cx, ProjectPanel::new);
9184 cx.run_until_parked();
9185
9186 #[rustfmt::skip]
9187 assert_eq!(
9188 visible_entries_as_strings(&panel, 0..10, cx),
9189 &[
9190 "v root1",
9191 " > dir1",
9192 " > dir2",
9193 " file4.txt",
9194 ],
9195 "With hide_root=false and single worktree, root should be visible"
9196 );
9197 }
9198
9199 // Test 2: Single worktree with hide_root = true
9200 {
9201 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9202 let window =
9203 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9204 let workspace = window
9205 .read_with(cx, |mw, _| mw.workspace().clone())
9206 .unwrap();
9207 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9208
9209 // Set hide_root to true
9210 cx.update(|_, cx| {
9211 let settings = *ProjectPanelSettings::get_global(cx);
9212 ProjectPanelSettings::override_global(
9213 ProjectPanelSettings {
9214 hide_root: true,
9215 ..settings
9216 },
9217 cx,
9218 );
9219 });
9220
9221 let panel = workspace.update_in(cx, ProjectPanel::new);
9222 cx.run_until_parked();
9223
9224 assert_eq!(
9225 visible_entries_as_strings(&panel, 0..10, cx),
9226 &["> dir1", "> dir2", " file4.txt",],
9227 "With hide_root=true and single worktree, root should be hidden"
9228 );
9229
9230 // Test expanding directories still works without root
9231 toggle_expand_dir(&panel, "root1/dir1", cx);
9232 assert_eq!(
9233 visible_entries_as_strings(&panel, 0..10, cx),
9234 &[
9235 "v dir1 <== selected",
9236 " file1.txt",
9237 " file2.txt",
9238 "> dir2",
9239 " file4.txt",
9240 ],
9241 "Should be able to expand directories even when root is hidden"
9242 );
9243 }
9244
9245 // Test 3: Multiple worktrees with hide_root = true
9246 {
9247 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9248 let window =
9249 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9250 let workspace = window
9251 .read_with(cx, |mw, _| mw.workspace().clone())
9252 .unwrap();
9253 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9254
9255 // Set hide_root to true
9256 cx.update(|_, cx| {
9257 let settings = *ProjectPanelSettings::get_global(cx);
9258 ProjectPanelSettings::override_global(
9259 ProjectPanelSettings {
9260 hide_root: true,
9261 ..settings
9262 },
9263 cx,
9264 );
9265 });
9266
9267 let panel = workspace.update_in(cx, ProjectPanel::new);
9268 cx.run_until_parked();
9269
9270 assert_eq!(
9271 visible_entries_as_strings(&panel, 0..10, cx),
9272 &[
9273 "v root1",
9274 " > dir1",
9275 " > dir2",
9276 " file4.txt",
9277 "v root2",
9278 " > dir3",
9279 " file6.txt",
9280 ],
9281 "With hide_root=true and multiple worktrees, roots should still be visible"
9282 );
9283 }
9284
9285 // Test 4: Multiple worktrees with hide_root = false
9286 {
9287 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
9288 let window =
9289 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9290 let workspace = window
9291 .read_with(cx, |mw, _| mw.workspace().clone())
9292 .unwrap();
9293 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9294
9295 cx.update(|_, cx| {
9296 let settings = *ProjectPanelSettings::get_global(cx);
9297 ProjectPanelSettings::override_global(
9298 ProjectPanelSettings {
9299 hide_root: false,
9300 ..settings
9301 },
9302 cx,
9303 );
9304 });
9305
9306 let panel = workspace.update_in(cx, ProjectPanel::new);
9307 cx.run_until_parked();
9308
9309 assert_eq!(
9310 visible_entries_as_strings(&panel, 0..10, cx),
9311 &[
9312 "v root1",
9313 " > dir1",
9314 " > dir2",
9315 " file4.txt",
9316 "v root2",
9317 " > dir3",
9318 " file6.txt",
9319 ],
9320 "With hide_root=false and multiple worktrees, roots should be visible"
9321 );
9322 }
9323}
9324
9325#[gpui::test]
9326async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
9327 init_test_with_editor(cx);
9328
9329 let fs = FakeFs::new(cx.executor());
9330 fs.insert_tree(
9331 "/root",
9332 json!({
9333 "file1.txt": "content of file1",
9334 "file2.txt": "content of file2",
9335 "dir1": {
9336 "file3.txt": "content of file3"
9337 }
9338 }),
9339 )
9340 .await;
9341
9342 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9343 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9344 let workspace = window
9345 .read_with(cx, |mw, _| mw.workspace().clone())
9346 .unwrap();
9347 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9348 let panel = workspace.update_in(cx, ProjectPanel::new);
9349 cx.run_until_parked();
9350
9351 let file1_path = "root/file1.txt";
9352 let file2_path = "root/file2.txt";
9353 select_path_with_mark(&panel, file1_path, cx);
9354 select_path_with_mark(&panel, file2_path, cx);
9355
9356 panel.update_in(cx, |panel, window, cx| {
9357 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
9358 });
9359 cx.executor().run_until_parked();
9360
9361 workspace.update_in(cx, |workspace, _, cx| {
9362 let active_items = workspace
9363 .panes()
9364 .iter()
9365 .filter_map(|pane| pane.read(cx).active_item())
9366 .collect::<Vec<_>>();
9367 assert_eq!(active_items.len(), 1);
9368 let diff_view = active_items
9369 .into_iter()
9370 .next()
9371 .unwrap()
9372 .downcast::<FileDiffView>()
9373 .expect("Open item should be an FileDiffView");
9374 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
9375 assert_eq!(
9376 diff_view.tab_tooltip_text(cx).unwrap(),
9377 format!(
9378 "{} ↔ {}",
9379 rel_path(file1_path).display(PathStyle::local()),
9380 rel_path(file2_path).display(PathStyle::local())
9381 )
9382 );
9383 });
9384
9385 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
9386 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
9387 let worktree_id = panel.update(cx, |panel, cx| {
9388 panel
9389 .project
9390 .read(cx)
9391 .worktrees(cx)
9392 .next()
9393 .unwrap()
9394 .read(cx)
9395 .id()
9396 });
9397
9398 let expected_entries = [
9399 SelectedEntry {
9400 worktree_id,
9401 entry_id: file1_entry_id,
9402 },
9403 SelectedEntry {
9404 worktree_id,
9405 entry_id: file2_entry_id,
9406 },
9407 ];
9408 panel.update(cx, |panel, _cx| {
9409 assert_eq!(
9410 &panel.marked_entries, &expected_entries,
9411 "Should keep marked entries after comparison"
9412 );
9413 });
9414
9415 panel.update(cx, |panel, cx| {
9416 panel.project.update(cx, |_, cx| {
9417 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
9418 })
9419 });
9420
9421 panel.update(cx, |panel, _cx| {
9422 assert_eq!(
9423 &panel.marked_entries, &expected_entries,
9424 "Marked entries should persist after focusing back on the project panel"
9425 );
9426 });
9427}
9428
9429#[gpui::test]
9430async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
9431 init_test_with_editor(cx);
9432
9433 let fs = FakeFs::new(cx.executor());
9434 fs.insert_tree(
9435 "/root",
9436 json!({
9437 "file1.txt": "content of file1",
9438 "file2.txt": "content of file2",
9439 "dir1": {},
9440 "dir2": {
9441 "file3.txt": "content of file3"
9442 }
9443 }),
9444 )
9445 .await;
9446
9447 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9448 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9449 let workspace = window
9450 .read_with(cx, |mw, _| mw.workspace().clone())
9451 .unwrap();
9452 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9453 let panel = workspace.update_in(cx, ProjectPanel::new);
9454 cx.run_until_parked();
9455
9456 // Test 1: When only one file is selected, there should be no compare option
9457 select_path(&panel, "root/file1.txt", cx);
9458
9459 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9460 assert_eq!(
9461 selected_files, None,
9462 "Should not have compare option when only one file is selected"
9463 );
9464
9465 // Test 2: When multiple files are selected, there should be a compare option
9466 select_path_with_mark(&panel, "root/file1.txt", cx);
9467 select_path_with_mark(&panel, "root/file2.txt", cx);
9468
9469 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9470 assert!(
9471 selected_files.is_some(),
9472 "Should have files selected for comparison"
9473 );
9474 if let Some((file1, file2)) = selected_files {
9475 assert!(
9476 file1.to_string_lossy().ends_with("file1.txt")
9477 && file2.to_string_lossy().ends_with("file2.txt"),
9478 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
9479 );
9480 }
9481
9482 // Test 3: Selecting a directory shouldn't count as a comparable file
9483 select_path_with_mark(&panel, "root/dir1", cx);
9484
9485 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9486 assert!(
9487 selected_files.is_some(),
9488 "Directory selection should not affect comparable files"
9489 );
9490 if let Some((file1, file2)) = selected_files {
9491 assert!(
9492 file1.to_string_lossy().ends_with("file1.txt")
9493 && file2.to_string_lossy().ends_with("file2.txt"),
9494 "Selecting a directory should not affect the number of comparable files"
9495 );
9496 }
9497
9498 // Test 4: Selecting one more file
9499 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
9500
9501 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
9502 assert!(
9503 selected_files.is_some(),
9504 "Directory selection should not affect comparable files"
9505 );
9506 if let Some((file1, file2)) = selected_files {
9507 assert!(
9508 file1.to_string_lossy().ends_with("file2.txt")
9509 && file2.to_string_lossy().ends_with("file3.txt"),
9510 "Selecting a directory should not affect the number of comparable files"
9511 );
9512 }
9513}
9514
9515#[gpui::test]
9516async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root(
9517 cx: &mut gpui::TestAppContext,
9518) {
9519 init_test(cx);
9520
9521 let fs = FakeFs::new(cx.executor());
9522 fs.insert_tree(
9523 "/root",
9524 json!({
9525 "file.txt": "content",
9526 "dir": {},
9527 }),
9528 )
9529 .await;
9530
9531 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9532 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9533 let workspace = window
9534 .read_with(cx, |mw, _| mw.workspace().clone())
9535 .unwrap();
9536 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9537 let panel = workspace.update_in(cx, ProjectPanel::new);
9538 cx.run_until_parked();
9539
9540 select_path(&panel, "root/file.txt", cx);
9541 let selected_reveal_path = panel
9542 .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
9543 .expect("selected entry should produce a reveal path");
9544 assert!(
9545 selected_reveal_path.ends_with(Path::new("file.txt")),
9546 "Expected selected file path, got {:?}",
9547 selected_reveal_path
9548 );
9549
9550 panel.update(cx, |panel, _| {
9551 panel.selection = None;
9552 panel.marked_entries.clear();
9553 });
9554 let fallback_reveal_path = panel
9555 .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
9556 .expect("project root should be used when selection is empty");
9557 assert!(
9558 fallback_reveal_path.ends_with(Path::new("root")),
9559 "Expected worktree root path, got {:?}",
9560 fallback_reveal_path
9561 );
9562}
9563
9564#[gpui::test]
9565async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
9566 init_test(cx);
9567
9568 let fs = FakeFs::new(cx.executor());
9569 fs.insert_tree(
9570 "/root",
9571 json!({
9572 ".hidden-file.txt": "hidden file content",
9573 "visible-file.txt": "visible file content",
9574 ".hidden-parent-dir": {
9575 "nested-dir": {
9576 "file.txt": "file content",
9577 }
9578 },
9579 "visible-dir": {
9580 "file-in-visible.txt": "file content",
9581 "nested": {
9582 ".hidden-nested-dir": {
9583 ".double-hidden-dir": {
9584 "deep-file-1.txt": "deep content 1",
9585 "deep-file-2.txt": "deep content 2"
9586 },
9587 "hidden-nested-file-1.txt": "hidden nested 1",
9588 "hidden-nested-file-2.txt": "hidden nested 2"
9589 },
9590 "visible-nested-file.txt": "visible nested content"
9591 }
9592 }
9593 }),
9594 )
9595 .await;
9596
9597 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9598 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9599 let workspace = window
9600 .read_with(cx, |mw, _| mw.workspace().clone())
9601 .unwrap();
9602 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9603
9604 cx.update(|_, cx| {
9605 let settings = *ProjectPanelSettings::get_global(cx);
9606 ProjectPanelSettings::override_global(
9607 ProjectPanelSettings {
9608 hide_hidden: false,
9609 ..settings
9610 },
9611 cx,
9612 );
9613 });
9614
9615 let panel = workspace.update_in(cx, ProjectPanel::new);
9616 cx.run_until_parked();
9617
9618 toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
9619 toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
9620 toggle_expand_dir(&panel, "root/visible-dir", cx);
9621 toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
9622 toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
9623 toggle_expand_dir(
9624 &panel,
9625 "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
9626 cx,
9627 );
9628
9629 let expanded = [
9630 "v root",
9631 " v .hidden-parent-dir",
9632 " v nested-dir",
9633 " file.txt",
9634 " v visible-dir",
9635 " v nested",
9636 " v .hidden-nested-dir",
9637 " v .double-hidden-dir <== selected",
9638 " deep-file-1.txt",
9639 " deep-file-2.txt",
9640 " hidden-nested-file-1.txt",
9641 " hidden-nested-file-2.txt",
9642 " visible-nested-file.txt",
9643 " file-in-visible.txt",
9644 " .hidden-file.txt",
9645 " visible-file.txt",
9646 ];
9647
9648 assert_eq!(
9649 visible_entries_as_strings(&panel, 0..30, cx),
9650 &expanded,
9651 "With hide_hidden=false, contents of hidden nested directory should be visible"
9652 );
9653
9654 cx.update(|_, cx| {
9655 let settings = *ProjectPanelSettings::get_global(cx);
9656 ProjectPanelSettings::override_global(
9657 ProjectPanelSettings {
9658 hide_hidden: true,
9659 ..settings
9660 },
9661 cx,
9662 );
9663 });
9664
9665 panel.update_in(cx, |panel, window, cx| {
9666 panel.update_visible_entries(None, false, false, window, cx);
9667 });
9668 cx.run_until_parked();
9669
9670 assert_eq!(
9671 visible_entries_as_strings(&panel, 0..30, cx),
9672 &[
9673 "v root",
9674 " v visible-dir",
9675 " v nested",
9676 " visible-nested-file.txt",
9677 " file-in-visible.txt",
9678 " visible-file.txt",
9679 ],
9680 "With hide_hidden=false, contents of hidden nested directory should be visible"
9681 );
9682
9683 panel.update_in(cx, |panel, window, cx| {
9684 let settings = *ProjectPanelSettings::get_global(cx);
9685 ProjectPanelSettings::override_global(
9686 ProjectPanelSettings {
9687 hide_hidden: false,
9688 ..settings
9689 },
9690 cx,
9691 );
9692 panel.update_visible_entries(None, false, false, window, cx);
9693 });
9694 cx.run_until_parked();
9695
9696 assert_eq!(
9697 visible_entries_as_strings(&panel, 0..30, cx),
9698 &expanded,
9699 "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
9700 );
9701}
9702
9703fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
9704 let path = rel_path(path);
9705 panel.update_in(cx, |panel, window, cx| {
9706 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9707 let worktree = worktree.read(cx);
9708 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9709 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9710 panel.update_visible_entries(
9711 Some((worktree.id(), entry_id)),
9712 false,
9713 false,
9714 window,
9715 cx,
9716 );
9717 return;
9718 }
9719 }
9720 panic!("no worktree for path {:?}", path);
9721 });
9722 cx.run_until_parked();
9723}
9724
9725fn select_path_with_mark(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
9726 let path = rel_path(path);
9727 panel.update(cx, |panel, cx| {
9728 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9729 let worktree = worktree.read(cx);
9730 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9731 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9732 let entry = crate::SelectedEntry {
9733 worktree_id: worktree.id(),
9734 entry_id,
9735 };
9736 if !panel.marked_entries.contains(&entry) {
9737 panel.marked_entries.push(entry);
9738 }
9739 panel.selection = Some(entry);
9740 return;
9741 }
9742 }
9743 panic!("no worktree for path {:?}", path);
9744 });
9745}
9746
9747/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
9748/// `active_ancestor_path` is the path to the folded component that should be active.
9749fn select_folded_path_with_mark(
9750 panel: &Entity<ProjectPanel>,
9751 leaf_path: &str,
9752 active_ancestor_path: &str,
9753 cx: &mut VisualTestContext,
9754) {
9755 select_path_with_mark(panel, leaf_path, cx);
9756 set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
9757}
9758
9759fn set_folded_active_ancestor(
9760 panel: &Entity<ProjectPanel>,
9761 leaf_path: &str,
9762 active_ancestor_path: &str,
9763 cx: &mut VisualTestContext,
9764) {
9765 let leaf_path = rel_path(leaf_path);
9766 let active_ancestor_path = rel_path(active_ancestor_path);
9767 panel.update(cx, |panel, cx| {
9768 let mut leaf_entry_id = None;
9769 let mut target_entry_id = None;
9770
9771 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9772 let worktree = worktree.read(cx);
9773 if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
9774 leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
9775 }
9776 if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
9777 target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
9778 }
9779 }
9780
9781 let leaf_entry_id =
9782 leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
9783 let target_entry_id = target_entry_id
9784 .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
9785 let folded_ancestors = panel
9786 .state
9787 .ancestors
9788 .get_mut(&leaf_entry_id)
9789 .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
9790 let ancestor_ids = folded_ancestors.ancestors.clone();
9791
9792 let mut depth_for_target = None;
9793 for depth in 0..ancestor_ids.len() {
9794 let resolved_entry_id = if depth == 0 {
9795 leaf_entry_id
9796 } else {
9797 ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
9798 };
9799 if resolved_entry_id == target_entry_id {
9800 depth_for_target = Some(depth);
9801 break;
9802 }
9803 }
9804
9805 folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
9806 panic!(
9807 "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
9808 )
9809 });
9810 });
9811}
9812
9813fn drag_selection_to(
9814 panel: &Entity<ProjectPanel>,
9815 target_path: &str,
9816 is_file: bool,
9817 cx: &mut VisualTestContext,
9818) {
9819 let target_entry = find_project_entry(panel, target_path, cx)
9820 .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
9821
9822 panel.update_in(cx, |panel, window, cx| {
9823 let selection = panel
9824 .selection
9825 .expect("a selection is required before dragging");
9826 let drag = DraggedSelection {
9827 active_selection: SelectedEntry {
9828 worktree_id: selection.worktree_id,
9829 entry_id: panel.resolve_entry(selection.entry_id),
9830 },
9831 marked_selections: Arc::from(panel.marked_entries.clone()),
9832 };
9833 panel.drag_onto(&drag, target_entry, is_file, window, cx);
9834 });
9835 cx.executor().run_until_parked();
9836}
9837
9838fn find_project_entry(
9839 panel: &Entity<ProjectPanel>,
9840 path: &str,
9841 cx: &mut VisualTestContext,
9842) -> Option<ProjectEntryId> {
9843 let path = rel_path(path);
9844 panel.update(cx, |panel, cx| {
9845 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9846 let worktree = worktree.read(cx);
9847 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9848 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9849 }
9850 }
9851 panic!("no worktree for path {path:?}");
9852 })
9853}
9854
9855fn visible_entries_as_strings(
9856 panel: &Entity<ProjectPanel>,
9857 range: Range<usize>,
9858 cx: &mut VisualTestContext,
9859) -> Vec<String> {
9860 let mut result = Vec::new();
9861 let mut project_entries = HashSet::default();
9862 let mut has_editor = false;
9863
9864 panel.update_in(cx, |panel, window, cx| {
9865 panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
9866 if details.is_editing {
9867 assert!(!has_editor, "duplicate editor entry");
9868 has_editor = true;
9869 } else {
9870 assert!(
9871 project_entries.insert(project_entry),
9872 "duplicate project entry {:?} {:?}",
9873 project_entry,
9874 details
9875 );
9876 }
9877
9878 let indent = " ".repeat(details.depth);
9879 let icon = if details.kind.is_dir() {
9880 if details.is_expanded { "v " } else { "> " }
9881 } else {
9882 " "
9883 };
9884 #[cfg(windows)]
9885 let filename = details.filename.replace("\\", "/");
9886 #[cfg(not(windows))]
9887 let filename = details.filename;
9888 let name = if details.is_editing {
9889 format!("[EDITOR: '{}']", filename)
9890 } else if details.is_processing {
9891 format!("[PROCESSING: '{}']", filename)
9892 } else {
9893 filename
9894 };
9895 let selected = if details.is_selected {
9896 " <== selected"
9897 } else {
9898 ""
9899 };
9900 let marked = if details.is_marked {
9901 " <== marked"
9902 } else {
9903 ""
9904 };
9905
9906 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9907 });
9908 });
9909
9910 result
9911}
9912
9913/// Test that missing sort_mode field defaults to DirectoriesFirst
9914#[gpui::test]
9915async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
9916 init_test(cx);
9917
9918 // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
9919 let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
9920 assert_eq!(
9921 default_settings.sort_mode,
9922 settings::ProjectPanelSortMode::DirectoriesFirst,
9923 "sort_mode should default to DirectoriesFirst"
9924 );
9925}
9926
9927/// Test sort modes: DirectoriesFirst (default) vs Mixed
9928#[gpui::test]
9929async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
9930 init_test(cx);
9931
9932 let fs = FakeFs::new(cx.executor());
9933 fs.insert_tree(
9934 "/root",
9935 json!({
9936 "zebra.txt": "",
9937 "Apple": {},
9938 "banana.rs": "",
9939 "Carrot": {},
9940 "aardvark.txt": "",
9941 }),
9942 )
9943 .await;
9944
9945 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9946 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9947 let workspace = window
9948 .read_with(cx, |mw, _| mw.workspace().clone())
9949 .unwrap();
9950 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9951 let panel = workspace.update_in(cx, ProjectPanel::new);
9952 cx.run_until_parked();
9953
9954 // Default sort mode should be DirectoriesFirst
9955 assert_eq!(
9956 visible_entries_as_strings(&panel, 0..50, cx),
9957 &[
9958 "v root",
9959 " > Apple",
9960 " > Carrot",
9961 " aardvark.txt",
9962 " banana.rs",
9963 " zebra.txt",
9964 ]
9965 );
9966}
9967
9968#[gpui::test]
9969async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
9970 init_test(cx);
9971
9972 let fs = FakeFs::new(cx.executor());
9973 fs.insert_tree(
9974 "/root",
9975 json!({
9976 "Zebra.txt": "",
9977 "apple": {},
9978 "Banana.rs": "",
9979 "carrot": {},
9980 "Aardvark.txt": "",
9981 }),
9982 )
9983 .await;
9984
9985 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9986 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9987 let workspace = window
9988 .read_with(cx, |mw, _| mw.workspace().clone())
9989 .unwrap();
9990 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9991
9992 // Switch to Mixed mode
9993 cx.update(|_, cx| {
9994 cx.update_global::<SettingsStore, _>(|store, cx| {
9995 store.update_user_settings(cx, |settings| {
9996 settings.project_panel.get_or_insert_default().sort_mode =
9997 Some(settings::ProjectPanelSortMode::Mixed);
9998 });
9999 });
10000 });
10001
10002 let panel = workspace.update_in(cx, ProjectPanel::new);
10003 cx.run_until_parked();
10004
10005 // Mixed mode: case-insensitive sorting
10006 // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
10007 assert_eq!(
10008 visible_entries_as_strings(&panel, 0..50, cx),
10009 &[
10010 "v root",
10011 " Aardvark.txt",
10012 " > apple",
10013 " Banana.rs",
10014 " > carrot",
10015 " Zebra.txt",
10016 ]
10017 );
10018}
10019
10020#[gpui::test]
10021async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
10022 init_test(cx);
10023
10024 let fs = FakeFs::new(cx.executor());
10025 fs.insert_tree(
10026 "/root",
10027 json!({
10028 "Zebra.txt": "",
10029 "apple": {},
10030 "Banana.rs": "",
10031 "carrot": {},
10032 "Aardvark.txt": "",
10033 }),
10034 )
10035 .await;
10036
10037 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10038 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10039 let workspace = window
10040 .read_with(cx, |mw, _| mw.workspace().clone())
10041 .unwrap();
10042 let cx = &mut VisualTestContext::from_window(window.into(), cx);
10043
10044 // Switch to FilesFirst mode
10045 cx.update(|_, cx| {
10046 cx.update_global::<SettingsStore, _>(|store, cx| {
10047 store.update_user_settings(cx, |settings| {
10048 settings.project_panel.get_or_insert_default().sort_mode =
10049 Some(settings::ProjectPanelSortMode::FilesFirst);
10050 });
10051 });
10052 });
10053
10054 let panel = workspace.update_in(cx, ProjectPanel::new);
10055 cx.run_until_parked();
10056
10057 // FilesFirst mode: files first, then directories (both case-insensitive)
10058 assert_eq!(
10059 visible_entries_as_strings(&panel, 0..50, cx),
10060 &[
10061 "v root",
10062 " Aardvark.txt",
10063 " Banana.rs",
10064 " Zebra.txt",
10065 " > apple",
10066 " > carrot",
10067 ]
10068 );
10069}
10070
10071#[gpui::test]
10072async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
10073 init_test(cx);
10074
10075 let fs = FakeFs::new(cx.executor());
10076 fs.insert_tree(
10077 "/root",
10078 json!({
10079 "file2.txt": "",
10080 "dir1": {},
10081 "file1.txt": "",
10082 }),
10083 )
10084 .await;
10085
10086 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
10087 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10088 let workspace = window
10089 .read_with(cx, |mw, _| mw.workspace().clone())
10090 .unwrap();
10091 let cx = &mut VisualTestContext::from_window(window.into(), cx);
10092 let panel = workspace.update_in(cx, ProjectPanel::new);
10093 cx.run_until_parked();
10094
10095 // Initially DirectoriesFirst
10096 assert_eq!(
10097 visible_entries_as_strings(&panel, 0..50, cx),
10098 &["v root", " > dir1", " file1.txt", " file2.txt",]
10099 );
10100
10101 // Toggle to Mixed
10102 cx.update(|_, cx| {
10103 cx.update_global::<SettingsStore, _>(|store, cx| {
10104 store.update_user_settings(cx, |settings| {
10105 settings.project_panel.get_or_insert_default().sort_mode =
10106 Some(settings::ProjectPanelSortMode::Mixed);
10107 });
10108 });
10109 });
10110 cx.run_until_parked();
10111
10112 assert_eq!(
10113 visible_entries_as_strings(&panel, 0..50, cx),
10114 &["v root", " > dir1", " file1.txt", " file2.txt",]
10115 );
10116
10117 // Toggle back to DirectoriesFirst
10118 cx.update(|_, cx| {
10119 cx.update_global::<SettingsStore, _>(|store, cx| {
10120 store.update_user_settings(cx, |settings| {
10121 settings.project_panel.get_or_insert_default().sort_mode =
10122 Some(settings::ProjectPanelSortMode::DirectoriesFirst);
10123 });
10124 });
10125 });
10126 cx.run_until_parked();
10127
10128 assert_eq!(
10129 visible_entries_as_strings(&panel, 0..50, cx),
10130 &["v root", " > dir1", " file1.txt", " file2.txt",]
10131 );
10132}
10133
10134#[gpui::test]
10135async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
10136 cx: &mut gpui::TestAppContext,
10137) {
10138 init_test(cx);
10139
10140 // parent: accept
10141 run_create_file_in_folded_path_case(
10142 "parent",
10143 "root1/parent",
10144 "file_in_parent.txt",
10145 &[
10146 "v root1",
10147 " v parent",
10148 " > subdir/child",
10149 " [EDITOR: ''] <== selected",
10150 ],
10151 &[
10152 "v root1",
10153 " v parent",
10154 " > subdir/child",
10155 " file_in_parent.txt <== selected <== marked",
10156 ],
10157 true,
10158 cx,
10159 )
10160 .await;
10161
10162 // parent: cancel
10163 run_create_file_in_folded_path_case(
10164 "parent",
10165 "root1/parent",
10166 "file_in_parent.txt",
10167 &[
10168 "v root1",
10169 " v parent",
10170 " > subdir/child",
10171 " [EDITOR: ''] <== selected",
10172 ],
10173 &["v root1", " > parent/subdir/child <== selected"],
10174 false,
10175 cx,
10176 )
10177 .await;
10178
10179 // subdir: accept
10180 run_create_file_in_folded_path_case(
10181 "subdir",
10182 "root1/parent/subdir",
10183 "file_in_subdir.txt",
10184 &[
10185 "v root1",
10186 " v parent/subdir",
10187 " > child",
10188 " [EDITOR: ''] <== selected",
10189 ],
10190 &[
10191 "v root1",
10192 " v parent/subdir",
10193 " > child",
10194 " file_in_subdir.txt <== selected <== marked",
10195 ],
10196 true,
10197 cx,
10198 )
10199 .await;
10200
10201 // subdir: cancel
10202 run_create_file_in_folded_path_case(
10203 "subdir",
10204 "root1/parent/subdir",
10205 "file_in_subdir.txt",
10206 &[
10207 "v root1",
10208 " v parent/subdir",
10209 " > child",
10210 " [EDITOR: ''] <== selected",
10211 ],
10212 &["v root1", " > parent/subdir/child <== selected"],
10213 false,
10214 cx,
10215 )
10216 .await;
10217
10218 // child: accept
10219 run_create_file_in_folded_path_case(
10220 "child",
10221 "root1/parent/subdir/child",
10222 "file_in_child.txt",
10223 &[
10224 "v root1",
10225 " v parent/subdir/child",
10226 " [EDITOR: ''] <== selected",
10227 ],
10228 &[
10229 "v root1",
10230 " v parent/subdir/child",
10231 " file_in_child.txt <== selected <== marked",
10232 ],
10233 true,
10234 cx,
10235 )
10236 .await;
10237
10238 // child: cancel
10239 run_create_file_in_folded_path_case(
10240 "child",
10241 "root1/parent/subdir/child",
10242 "file_in_child.txt",
10243 &[
10244 "v root1",
10245 " v parent/subdir/child",
10246 " [EDITOR: ''] <== selected",
10247 ],
10248 &["v root1", " v parent/subdir/child <== selected"],
10249 false,
10250 cx,
10251 )
10252 .await;
10253}
10254
10255#[gpui::test]
10256async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
10257 cx: &mut gpui::TestAppContext,
10258) {
10259 init_test(cx);
10260
10261 let fs = FakeFs::new(cx.executor());
10262 fs.insert_tree(
10263 "/root1",
10264 json!({
10265 "parent": {
10266 "subdir": {
10267 "child": {},
10268 }
10269 }
10270 }),
10271 )
10272 .await;
10273
10274 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
10275 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10276 let workspace = window
10277 .read_with(cx, |mw, _| mw.workspace().clone())
10278 .unwrap();
10279 let cx = &mut VisualTestContext::from_window(window.into(), cx);
10280
10281 let panel = workspace.update_in(cx, |workspace, window, cx| {
10282 let panel = ProjectPanel::new(workspace, window, cx);
10283 workspace.add_panel(panel.clone(), window, cx);
10284 panel
10285 });
10286
10287 cx.update(|_, cx| {
10288 let settings = *ProjectPanelSettings::get_global(cx);
10289 ProjectPanelSettings::override_global(
10290 ProjectPanelSettings {
10291 auto_fold_dirs: true,
10292 ..settings
10293 },
10294 cx,
10295 );
10296 });
10297
10298 panel.update_in(cx, |panel, window, cx| {
10299 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
10300 });
10301 cx.run_until_parked();
10302
10303 select_folded_path_with_mark(
10304 &panel,
10305 "root1/parent/subdir/child",
10306 "root1/parent/subdir",
10307 cx,
10308 );
10309 panel.update(cx, |panel, _| {
10310 panel.marked_entries.clear();
10311 });
10312
10313 let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
10314 .expect("parent directory should exist for this test");
10315 let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
10316 .expect("subdir directory should exist for this test");
10317 let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
10318 .expect("child directory should exist for this test");
10319
10320 panel.update(cx, |panel, _| {
10321 let selection = panel
10322 .selection
10323 .expect("leaf directory should be selected before creating a new entry");
10324 assert_eq!(
10325 selection.entry_id, child_entry_id,
10326 "initial selection should be the folded leaf entry"
10327 );
10328 assert_eq!(
10329 panel.resolve_entry(selection.entry_id),
10330 subdir_entry_id,
10331 "active folded component should start at subdir"
10332 );
10333 });
10334
10335 panel.update_in(cx, |panel, window, cx| {
10336 panel.deploy_context_menu(
10337 gpui::point(gpui::px(1.), gpui::px(1.)),
10338 child_entry_id,
10339 window,
10340 cx,
10341 );
10342 panel.new_file(&NewFile, window, cx);
10343 });
10344 cx.run_until_parked();
10345 panel.update_in(cx, |panel, window, cx| {
10346 assert!(panel.filename_editor.read(cx).is_focused(window));
10347 });
10348 cx.run_until_parked();
10349
10350 set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
10351
10352 panel.update_in(cx, |panel, window, cx| {
10353 panel.deploy_context_menu(
10354 gpui::point(gpui::px(2.), gpui::px(2.)),
10355 subdir_entry_id,
10356 window,
10357 cx,
10358 );
10359 });
10360 cx.run_until_parked();
10361
10362 panel.update(cx, |panel, _| {
10363 assert!(
10364 panel.state.edit_state.is_none(),
10365 "opening another context menu should blur the filename editor and discard edit state"
10366 );
10367 let selection = panel
10368 .selection
10369 .expect("selection should restore to the previously focused leaf entry");
10370 assert_eq!(
10371 selection.entry_id, child_entry_id,
10372 "blur-driven cancellation should restore the previous leaf selection"
10373 );
10374 assert_eq!(
10375 panel.resolve_entry(selection.entry_id),
10376 parent_entry_id,
10377 "temporary unfolded pending state should preserve the active ancestor chosen before blur"
10378 );
10379 });
10380
10381 panel.update_in(cx, |panel, window, cx| {
10382 panel.new_file(&NewFile, window, cx);
10383 });
10384 cx.run_until_parked();
10385 assert_eq!(
10386 visible_entries_as_strings(&panel, 0..10, cx),
10387 &[
10388 "v root1",
10389 " v parent",
10390 " > subdir/child",
10391 " [EDITOR: ''] <== selected",
10392 ],
10393 "new file after blur should use the preserved active ancestor"
10394 );
10395 panel.update(cx, |panel, _| {
10396 let edit_state = panel
10397 .state
10398 .edit_state
10399 .as_ref()
10400 .expect("new file should enter edit state");
10401 assert_eq!(
10402 edit_state.temporarily_unfolded,
10403 Some(parent_entry_id),
10404 "temporary unfolding should now target parent after restoring the active ancestor"
10405 );
10406 });
10407
10408 let file_name = "created_after_blur.txt";
10409 panel
10410 .update_in(cx, |panel, window, cx| {
10411 panel.filename_editor.update(cx, |editor, cx| {
10412 editor.set_text(file_name, window, cx);
10413 });
10414 panel.confirm_edit(true, window, cx).expect(
10415 "confirm_edit should start creation for the file created after blur transition",
10416 )
10417 })
10418 .await
10419 .expect("creating file after blur transition should succeed");
10420 cx.run_until_parked();
10421
10422 assert!(
10423 fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
10424 .await,
10425 "file should be created under parent after active ancestor is restored to parent"
10426 );
10427 assert!(
10428 !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
10429 .await,
10430 "file should not be created under subdir when parent is the active ancestor"
10431 );
10432}
10433
10434async fn run_create_file_in_folded_path_case(
10435 case_name: &str,
10436 active_ancestor_path: &str,
10437 created_file_name: &str,
10438 expected_temporary_state: &[&str],
10439 expected_final_state: &[&str],
10440 accept_creation: bool,
10441 cx: &mut gpui::TestAppContext,
10442) {
10443 let expected_collapsed_state = &["v root1", " > parent/subdir/child <== selected"];
10444
10445 let fs = FakeFs::new(cx.executor());
10446 fs.insert_tree(
10447 "/root1",
10448 json!({
10449 "parent": {
10450 "subdir": {
10451 "child": {},
10452 }
10453 }
10454 }),
10455 )
10456 .await;
10457
10458 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
10459 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10460 let workspace = window
10461 .read_with(cx, |mw, _| mw.workspace().clone())
10462 .unwrap();
10463 let cx = &mut VisualTestContext::from_window(window.into(), cx);
10464
10465 let panel = workspace.update_in(cx, |workspace, window, cx| {
10466 let panel = ProjectPanel::new(workspace, window, cx);
10467 workspace.add_panel(panel.clone(), window, cx);
10468 panel
10469 });
10470
10471 cx.update(|_, cx| {
10472 let settings = *ProjectPanelSettings::get_global(cx);
10473 ProjectPanelSettings::override_global(
10474 ProjectPanelSettings {
10475 auto_fold_dirs: true,
10476 ..settings
10477 },
10478 cx,
10479 );
10480 });
10481
10482 panel.update_in(cx, |panel, window, cx| {
10483 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
10484 });
10485 cx.run_until_parked();
10486
10487 select_folded_path_with_mark(
10488 &panel,
10489 "root1/parent/subdir/child",
10490 active_ancestor_path,
10491 cx,
10492 );
10493 panel.update(cx, |panel, _| {
10494 panel.marked_entries.clear();
10495 });
10496
10497 assert_eq!(
10498 visible_entries_as_strings(&panel, 0..10, cx),
10499 expected_collapsed_state,
10500 "case '{}' should start from a folded state",
10501 case_name
10502 );
10503
10504 panel.update_in(cx, |panel, window, cx| {
10505 panel.new_file(&NewFile, window, cx);
10506 });
10507 cx.run_until_parked();
10508 panel.update_in(cx, |panel, window, cx| {
10509 assert!(panel.filename_editor.read(cx).is_focused(window));
10510 });
10511 cx.run_until_parked();
10512 assert_eq!(
10513 visible_entries_as_strings(&panel, 0..10, cx),
10514 expected_temporary_state,
10515 "case '{}' ({}) should temporarily unfold the active ancestor while editing",
10516 case_name,
10517 if accept_creation { "accept" } else { "cancel" }
10518 );
10519
10520 let relative_directory = active_ancestor_path
10521 .strip_prefix("root1/")
10522 .expect("active_ancestor_path should start with root1/");
10523 let created_file_path = PathBuf::from("/root1")
10524 .join(relative_directory)
10525 .join(created_file_name);
10526
10527 if accept_creation {
10528 panel
10529 .update_in(cx, |panel, window, cx| {
10530 panel.filename_editor.update(cx, |editor, cx| {
10531 editor.set_text(created_file_name, window, cx);
10532 });
10533 panel.confirm_edit(true, window, cx).unwrap()
10534 })
10535 .await
10536 .unwrap();
10537 cx.run_until_parked();
10538
10539 assert_eq!(
10540 visible_entries_as_strings(&panel, 0..10, cx),
10541 expected_final_state,
10542 "case '{}' should keep the newly created file selected and marked after accept",
10543 case_name
10544 );
10545 assert!(
10546 fs.is_file(created_file_path.as_path()).await,
10547 "case '{}' should create file '{}'",
10548 case_name,
10549 created_file_path.display()
10550 );
10551 } else {
10552 panel.update_in(cx, |panel, window, cx| {
10553 panel.cancel(&Cancel, window, cx);
10554 });
10555 cx.run_until_parked();
10556
10557 assert_eq!(
10558 visible_entries_as_strings(&panel, 0..10, cx),
10559 expected_final_state,
10560 "case '{}' should keep the expected panel state after cancel",
10561 case_name
10562 );
10563 assert!(
10564 !fs.is_file(created_file_path.as_path()).await,
10565 "case '{}' should not create a file after cancel",
10566 case_name
10567 );
10568 }
10569}
10570
10571pub(crate) fn init_test(cx: &mut TestAppContext) {
10572 cx.update(|cx| {
10573 let settings_store = SettingsStore::test(cx);
10574 cx.set_global(settings_store);
10575 theme_settings::init(theme::LoadThemes::JustBase, cx);
10576 crate::init(cx);
10577
10578 cx.update_global::<SettingsStore, _>(|store, cx| {
10579 store.update_user_settings(cx, |settings| {
10580 settings
10581 .project_panel
10582 .get_or_insert_default()
10583 .auto_fold_dirs = Some(false);
10584 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
10585 });
10586 });
10587 });
10588}
10589
10590fn init_test_with_editor(cx: &mut TestAppContext) {
10591 cx.update(|cx| {
10592 let app_state = AppState::test(cx);
10593 theme_settings::init(theme::LoadThemes::JustBase, cx);
10594 editor::init(cx);
10595 crate::init(cx);
10596 workspace::init(app_state, cx);
10597
10598 cx.update_global::<SettingsStore, _>(|store, cx| {
10599 store.update_user_settings(cx, |settings| {
10600 settings
10601 .project_panel
10602 .get_or_insert_default()
10603 .auto_fold_dirs = Some(false);
10604 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
10605 });
10606 });
10607 });
10608}
10609
10610fn set_auto_open_settings(
10611 cx: &mut TestAppContext,
10612 auto_open_settings: ProjectPanelAutoOpenSettings,
10613) {
10614 cx.update(|cx| {
10615 cx.update_global::<SettingsStore, _>(|store, cx| {
10616 store.update_user_settings(cx, |settings| {
10617 settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
10618 });
10619 })
10620 });
10621}
10622
10623fn ensure_single_file_is_opened(
10624 workspace: &Entity<Workspace>,
10625 expected_path: &str,
10626 cx: &mut VisualTestContext,
10627) {
10628 workspace.update_in(cx, |workspace, _, cx| {
10629 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
10630 assert_eq!(worktrees.len(), 1);
10631 let worktree_id = worktrees[0].read(cx).id();
10632
10633 let open_project_paths = workspace
10634 .panes()
10635 .iter()
10636 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
10637 .collect::<Vec<_>>();
10638 assert_eq!(
10639 open_project_paths,
10640 vec![ProjectPath {
10641 worktree_id,
10642 path: Arc::from(rel_path(expected_path))
10643 }],
10644 "Should have opened file, selected in project panel"
10645 );
10646 });
10647}
10648
10649fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
10650 assert!(
10651 !cx.has_pending_prompt(),
10652 "Should have no prompts before the deletion"
10653 );
10654 panel.update_in(cx, |panel, window, cx| {
10655 panel.delete(&Delete { skip_prompt: false }, window, cx)
10656 });
10657 assert!(
10658 cx.has_pending_prompt(),
10659 "Should have a prompt after the deletion"
10660 );
10661 cx.simulate_prompt_answer("Delete");
10662 assert!(
10663 !cx.has_pending_prompt(),
10664 "Should have no prompts after prompt was replied to"
10665 );
10666 cx.executor().run_until_parked();
10667}
10668
10669fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
10670 assert!(
10671 !cx.has_pending_prompt(),
10672 "Should have no prompts before the deletion"
10673 );
10674 panel.update_in(cx, |panel, window, cx| {
10675 panel.delete(&Delete { skip_prompt: true }, window, cx)
10676 });
10677 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
10678 cx.executor().run_until_parked();
10679}
10680
10681fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
10682 assert!(
10683 !cx.has_pending_prompt(),
10684 "Should have no prompts after deletion operation closes the file"
10685 );
10686 workspace.update_in(cx, |workspace, _window, cx| {
10687 let open_project_paths = workspace
10688 .panes()
10689 .iter()
10690 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
10691 .collect::<Vec<_>>();
10692 assert!(
10693 open_project_paths.is_empty(),
10694 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
10695 );
10696 });
10697}
10698
10699struct TestProjectItemView {
10700 focus_handle: FocusHandle,
10701 path: ProjectPath,
10702}
10703
10704struct TestProjectItem {
10705 path: ProjectPath,
10706}
10707
10708impl project::ProjectItem for TestProjectItem {
10709 fn try_open(
10710 _project: &Entity<Project>,
10711 path: &ProjectPath,
10712 cx: &mut App,
10713 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
10714 let path = path.clone();
10715 Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
10716 }
10717
10718 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
10719 None
10720 }
10721
10722 fn project_path(&self, _: &App) -> Option<ProjectPath> {
10723 Some(self.path.clone())
10724 }
10725
10726 fn is_dirty(&self) -> bool {
10727 false
10728 }
10729}
10730
10731impl ProjectItem for TestProjectItemView {
10732 type Item = TestProjectItem;
10733
10734 fn for_project_item(
10735 _: Entity<Project>,
10736 _: Option<&Pane>,
10737 project_item: Entity<Self::Item>,
10738 _: &mut Window,
10739 cx: &mut Context<Self>,
10740 ) -> Self
10741 where
10742 Self: Sized,
10743 {
10744 Self {
10745 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
10746 focus_handle: cx.focus_handle(),
10747 }
10748 }
10749}
10750
10751impl Item for TestProjectItemView {
10752 type Event = ();
10753
10754 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
10755 "Test".into()
10756 }
10757}
10758
10759impl EventEmitter<()> for TestProjectItemView {}
10760
10761impl Focusable for TestProjectItemView {
10762 fn focus_handle(&self, _: &App) -> FocusHandle {
10763 self.focus_handle.clone()
10764 }
10765}
10766
10767impl Render for TestProjectItemView {
10768 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
10769 Empty
10770 }
10771}