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