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