1use super::*;
2// use crate::undo::tests::{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, test::TestItem},
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(0),
1259 "Should select from the beginning of the filename"
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_paste_external_paths(cx: &mut gpui::TestAppContext) {
2000 init_test(cx);
2001 set_auto_open_settings(
2002 cx,
2003 ProjectPanelAutoOpenSettings {
2004 on_drop: Some(false),
2005 ..Default::default()
2006 },
2007 );
2008
2009 let fs = FakeFs::new(cx.executor());
2010 fs.insert_tree(
2011 path!("/root"),
2012 json!({
2013 "subdir": {}
2014 }),
2015 )
2016 .await;
2017
2018 fs.insert_tree(
2019 path!("/external"),
2020 json!({
2021 "new_file.rs": "fn main() {}"
2022 }),
2023 )
2024 .await;
2025
2026 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2027 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2028 let workspace = window
2029 .read_with(cx, |mw, _| mw.workspace().clone())
2030 .unwrap();
2031 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2032 let panel = workspace.update_in(cx, ProjectPanel::new);
2033 cx.run_until_parked();
2034
2035 cx.write_to_clipboard(ClipboardItem {
2036 entries: vec![GpuiClipboardEntry::ExternalPaths(ExternalPaths(
2037 smallvec::smallvec![PathBuf::from(path!("/external/new_file.rs"))],
2038 ))],
2039 });
2040
2041 select_path(&panel, "root/subdir", cx);
2042 panel.update_in(cx, |panel, window, cx| {
2043 panel.paste(&Default::default(), window, cx);
2044 });
2045 cx.executor().run_until_parked();
2046
2047 assert_eq!(
2048 visible_entries_as_strings(&panel, 0..50, cx),
2049 &[
2050 "v root",
2051 " v subdir",
2052 " new_file.rs <== selected",
2053 ],
2054 );
2055}
2056
2057#[gpui::test]
2058async fn test_copy_and_cut_write_to_system_clipboard(cx: &mut gpui::TestAppContext) {
2059 init_test(cx);
2060
2061 let fs = FakeFs::new(cx.executor());
2062 fs.insert_tree(
2063 path!("/root"),
2064 json!({
2065 "file_a.txt": "",
2066 "file_b.txt": ""
2067 }),
2068 )
2069 .await;
2070
2071 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2072 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2073 let workspace = window
2074 .read_with(cx, |mw, _| mw.workspace().clone())
2075 .unwrap();
2076 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2077 let panel = workspace.update_in(cx, ProjectPanel::new);
2078 cx.run_until_parked();
2079
2080 select_path(&panel, "root/file_a.txt", cx);
2081 panel.update_in(cx, |panel, window, cx| {
2082 panel.copy(&Default::default(), window, cx);
2083 });
2084
2085 let clipboard = cx
2086 .read_from_clipboard()
2087 .expect("clipboard should have content after copy");
2088 let text = clipboard.text().expect("clipboard should contain text");
2089 assert!(
2090 text.contains("file_a.txt"),
2091 "System clipboard should contain the copied file path, got: {text}"
2092 );
2093
2094 select_path(&panel, "root/file_b.txt", cx);
2095 panel.update_in(cx, |panel, window, cx| {
2096 panel.cut(&Default::default(), window, cx);
2097 });
2098
2099 let clipboard = cx
2100 .read_from_clipboard()
2101 .expect("clipboard should have content after cut");
2102 let text = clipboard.text().expect("clipboard should contain text");
2103 assert!(
2104 text.contains("file_b.txt"),
2105 "System clipboard should contain the cut file path, got: {text}"
2106 );
2107}
2108
2109#[gpui::test]
2110async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2111 init_test_with_editor(cx);
2112
2113 let fs = FakeFs::new(cx.executor());
2114 fs.insert_tree(
2115 path!("/src"),
2116 json!({
2117 "test": {
2118 "first.rs": "// First Rust file",
2119 "second.rs": "// Second Rust file",
2120 "third.rs": "// Third Rust file",
2121 }
2122 }),
2123 )
2124 .await;
2125
2126 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
2127 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2128 let workspace = window
2129 .read_with(cx, |mw, _| mw.workspace().clone())
2130 .unwrap();
2131 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2132 let panel = workspace.update_in(cx, ProjectPanel::new);
2133 cx.run_until_parked();
2134
2135 toggle_expand_dir(&panel, "src/test", cx);
2136 select_path(&panel, "src/test/first.rs", cx);
2137 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2138 cx.executor().run_until_parked();
2139 assert_eq!(
2140 visible_entries_as_strings(&panel, 0..10, cx),
2141 &[
2142 "v src",
2143 " v test",
2144 " first.rs <== selected <== marked",
2145 " second.rs",
2146 " third.rs"
2147 ]
2148 );
2149 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2150
2151 submit_deletion(&panel, cx);
2152 assert_eq!(
2153 visible_entries_as_strings(&panel, 0..10, cx),
2154 &[
2155 "v src",
2156 " v test",
2157 " second.rs <== selected",
2158 " third.rs"
2159 ],
2160 "Project panel should have no deleted file, no other file is selected in it"
2161 );
2162 ensure_no_open_items_and_panes(&workspace, cx);
2163
2164 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2165 cx.executor().run_until_parked();
2166 assert_eq!(
2167 visible_entries_as_strings(&panel, 0..10, cx),
2168 &[
2169 "v src",
2170 " v test",
2171 " second.rs <== selected <== marked",
2172 " third.rs"
2173 ]
2174 );
2175 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2176
2177 workspace.update_in(cx, |workspace, window, cx| {
2178 let active_items = workspace
2179 .panes()
2180 .iter()
2181 .filter_map(|pane| pane.read(cx).active_item())
2182 .collect::<Vec<_>>();
2183 assert_eq!(active_items.len(), 1);
2184 let open_editor = active_items
2185 .into_iter()
2186 .next()
2187 .unwrap()
2188 .downcast::<Editor>()
2189 .expect("Open item should be an editor");
2190 open_editor.update(cx, |editor, cx| {
2191 editor.set_text("Another text!", window, cx)
2192 });
2193 });
2194 submit_deletion_skipping_prompt(&panel, cx);
2195 assert_eq!(
2196 visible_entries_as_strings(&panel, 0..10, cx),
2197 &["v src", " v test", " third.rs <== selected"],
2198 "Project panel should have no deleted file, with one last file remaining"
2199 );
2200 ensure_no_open_items_and_panes(&workspace, cx);
2201}
2202
2203#[gpui::test]
2204async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) {
2205 init_test_with_editor(cx);
2206 set_auto_open_settings(
2207 cx,
2208 ProjectPanelAutoOpenSettings {
2209 on_create: Some(true),
2210 ..Default::default()
2211 },
2212 );
2213
2214 let fs = FakeFs::new(cx.executor());
2215 fs.insert_tree(path!("/root"), json!({})).await;
2216
2217 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2218 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2219 let workspace = window
2220 .read_with(cx, |mw, _| mw.workspace().clone())
2221 .unwrap();
2222 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2223 let panel = workspace.update_in(cx, ProjectPanel::new);
2224 cx.run_until_parked();
2225
2226 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2227 cx.run_until_parked();
2228 panel
2229 .update_in(cx, |panel, window, cx| {
2230 panel.filename_editor.update(cx, |editor, cx| {
2231 editor.set_text("auto-open.rs", window, cx);
2232 });
2233 panel.confirm_edit(true, window, cx).unwrap()
2234 })
2235 .await
2236 .unwrap();
2237 cx.run_until_parked();
2238
2239 ensure_single_file_is_opened(&workspace, "auto-open.rs", cx);
2240}
2241
2242#[gpui::test]
2243async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) {
2244 init_test_with_editor(cx);
2245 set_auto_open_settings(
2246 cx,
2247 ProjectPanelAutoOpenSettings {
2248 on_create: Some(false),
2249 ..Default::default()
2250 },
2251 );
2252
2253 let fs = FakeFs::new(cx.executor());
2254 fs.insert_tree(path!("/root"), json!({})).await;
2255
2256 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2257 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2258 let workspace = window
2259 .read_with(cx, |mw, _| mw.workspace().clone())
2260 .unwrap();
2261 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2262 let panel = workspace.update_in(cx, ProjectPanel::new);
2263 cx.run_until_parked();
2264
2265 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2266 cx.run_until_parked();
2267 panel
2268 .update_in(cx, |panel, window, cx| {
2269 panel.filename_editor.update(cx, |editor, cx| {
2270 editor.set_text("manual-open.rs", window, cx);
2271 });
2272 panel.confirm_edit(true, window, cx).unwrap()
2273 })
2274 .await
2275 .unwrap();
2276 cx.run_until_parked();
2277
2278 ensure_no_open_items_and_panes(&workspace, cx);
2279}
2280
2281#[gpui::test]
2282async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) {
2283 init_test_with_editor(cx);
2284 set_auto_open_settings(
2285 cx,
2286 ProjectPanelAutoOpenSettings {
2287 on_paste: Some(true),
2288 ..Default::default()
2289 },
2290 );
2291
2292 let fs = FakeFs::new(cx.executor());
2293 fs.insert_tree(
2294 path!("/root"),
2295 json!({
2296 "src": {
2297 "original.rs": ""
2298 },
2299 "target": {}
2300 }),
2301 )
2302 .await;
2303
2304 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2305 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2306 let workspace = window
2307 .read_with(cx, |mw, _| mw.workspace().clone())
2308 .unwrap();
2309 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2310 let panel = workspace.update_in(cx, ProjectPanel::new);
2311 cx.run_until_parked();
2312
2313 toggle_expand_dir(&panel, "root/src", cx);
2314 toggle_expand_dir(&panel, "root/target", cx);
2315
2316 select_path(&panel, "root/src/original.rs", cx);
2317 panel.update_in(cx, |panel, window, cx| {
2318 panel.copy(&Default::default(), window, cx);
2319 });
2320
2321 select_path(&panel, "root/target", cx);
2322 panel.update_in(cx, |panel, window, cx| {
2323 panel.paste(&Default::default(), window, cx);
2324 });
2325 cx.executor().run_until_parked();
2326
2327 ensure_single_file_is_opened(&workspace, "target/original.rs", cx);
2328}
2329
2330#[gpui::test]
2331async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) {
2332 init_test_with_editor(cx);
2333 set_auto_open_settings(
2334 cx,
2335 ProjectPanelAutoOpenSettings {
2336 on_paste: Some(false),
2337 ..Default::default()
2338 },
2339 );
2340
2341 let fs = FakeFs::new(cx.executor());
2342 fs.insert_tree(
2343 path!("/root"),
2344 json!({
2345 "src": {
2346 "original.rs": ""
2347 },
2348 "target": {}
2349 }),
2350 )
2351 .await;
2352
2353 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2354 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2355 let workspace = window
2356 .read_with(cx, |mw, _| mw.workspace().clone())
2357 .unwrap();
2358 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2359 let panel = workspace.update_in(cx, ProjectPanel::new);
2360 cx.run_until_parked();
2361
2362 toggle_expand_dir(&panel, "root/src", cx);
2363 toggle_expand_dir(&panel, "root/target", cx);
2364
2365 select_path(&panel, "root/src/original.rs", cx);
2366 panel.update_in(cx, |panel, window, cx| {
2367 panel.copy(&Default::default(), window, cx);
2368 });
2369
2370 select_path(&panel, "root/target", cx);
2371 panel.update_in(cx, |panel, window, cx| {
2372 panel.paste(&Default::default(), window, cx);
2373 });
2374 cx.executor().run_until_parked();
2375
2376 ensure_no_open_items_and_panes(&workspace, cx);
2377 assert!(
2378 find_project_entry(&panel, "root/target/original.rs", cx).is_some(),
2379 "Pasted entry should exist even when auto-open is disabled"
2380 );
2381}
2382
2383#[gpui::test]
2384async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) {
2385 init_test_with_editor(cx);
2386 set_auto_open_settings(
2387 cx,
2388 ProjectPanelAutoOpenSettings {
2389 on_drop: Some(true),
2390 ..Default::default()
2391 },
2392 );
2393
2394 let fs = FakeFs::new(cx.executor());
2395 fs.insert_tree(path!("/root"), json!({})).await;
2396
2397 let temp_dir = tempfile::tempdir().unwrap();
2398 let external_path = temp_dir.path().join("dropped.rs");
2399 std::fs::write(&external_path, "// dropped").unwrap();
2400 fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2401 .await;
2402
2403 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2404 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2405 let workspace = window
2406 .read_with(cx, |mw, _| mw.workspace().clone())
2407 .unwrap();
2408 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2409 let panel = workspace.update_in(cx, ProjectPanel::new);
2410 cx.run_until_parked();
2411
2412 let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2413 panel.update_in(cx, |panel, window, cx| {
2414 panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2415 });
2416 cx.executor().run_until_parked();
2417
2418 ensure_single_file_is_opened(&workspace, "dropped.rs", cx);
2419}
2420
2421#[gpui::test]
2422async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) {
2423 init_test_with_editor(cx);
2424 set_auto_open_settings(
2425 cx,
2426 ProjectPanelAutoOpenSettings {
2427 on_drop: Some(false),
2428 ..Default::default()
2429 },
2430 );
2431
2432 let fs = FakeFs::new(cx.executor());
2433 fs.insert_tree(path!("/root"), json!({})).await;
2434
2435 let temp_dir = tempfile::tempdir().unwrap();
2436 let external_path = temp_dir.path().join("manual.rs");
2437 std::fs::write(&external_path, "// dropped").unwrap();
2438 fs.insert_tree_from_real_fs(temp_dir.path(), temp_dir.path())
2439 .await;
2440
2441 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2442 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2443 let workspace = window
2444 .read_with(cx, |mw, _| mw.workspace().clone())
2445 .unwrap();
2446 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2447 let panel = workspace.update_in(cx, ProjectPanel::new);
2448 cx.run_until_parked();
2449
2450 let root_entry = find_project_entry(&panel, "root", cx).unwrap();
2451 panel.update_in(cx, |panel, window, cx| {
2452 panel.drop_external_files(std::slice::from_ref(&external_path), root_entry, window, cx);
2453 });
2454 cx.executor().run_until_parked();
2455
2456 ensure_no_open_items_and_panes(&workspace, cx);
2457 assert!(
2458 find_project_entry(&panel, "root/manual.rs", cx).is_some(),
2459 "Dropped entry should exist even when auto-open is disabled"
2460 );
2461}
2462
2463#[gpui::test]
2464async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2465 init_test_with_editor(cx);
2466
2467 let fs = FakeFs::new(cx.executor());
2468 fs.insert_tree(
2469 "/src",
2470 json!({
2471 "test": {
2472 "first.rs": "// First Rust file",
2473 "second.rs": "// Second Rust file",
2474 "third.rs": "// Third Rust file",
2475 }
2476 }),
2477 )
2478 .await;
2479
2480 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2481 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2482 let workspace = window
2483 .read_with(cx, |mw, _| mw.workspace().clone())
2484 .unwrap();
2485 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2486 let panel = workspace.update_in(cx, |workspace, window, cx| {
2487 let panel = ProjectPanel::new(workspace, window, cx);
2488 workspace.add_panel(panel.clone(), window, cx);
2489 panel
2490 });
2491 cx.run_until_parked();
2492
2493 select_path(&panel, "src", cx);
2494 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2495 cx.executor().run_until_parked();
2496 assert_eq!(
2497 visible_entries_as_strings(&panel, 0..10, cx),
2498 &[
2499 //
2500 "v src <== selected",
2501 " > test"
2502 ]
2503 );
2504 panel.update_in(cx, |panel, window, cx| {
2505 panel.new_directory(&NewDirectory, window, cx)
2506 });
2507 cx.run_until_parked();
2508 panel.update_in(cx, |panel, window, cx| {
2509 assert!(panel.filename_editor.read(cx).is_focused(window));
2510 });
2511 cx.executor().run_until_parked();
2512 assert_eq!(
2513 visible_entries_as_strings(&panel, 0..10, cx),
2514 &[
2515 //
2516 "v src",
2517 " > [EDITOR: ''] <== selected",
2518 " > test"
2519 ]
2520 );
2521 panel.update_in(cx, |panel, window, cx| {
2522 panel
2523 .filename_editor
2524 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2525 assert!(
2526 panel.confirm_edit(true, window, cx).is_none(),
2527 "Should not allow to confirm on conflicting new directory name"
2528 );
2529 });
2530 cx.executor().run_until_parked();
2531 panel.update_in(cx, |panel, window, cx| {
2532 assert!(
2533 panel.state.edit_state.is_some(),
2534 "Edit state should not be None after conflicting new directory name"
2535 );
2536 panel.cancel(&menu::Cancel, window, cx);
2537 });
2538 cx.run_until_parked();
2539 assert_eq!(
2540 visible_entries_as_strings(&panel, 0..10, cx),
2541 &[
2542 //
2543 "v src <== selected",
2544 " > test"
2545 ],
2546 "File list should be unchanged after failed folder create confirmation"
2547 );
2548
2549 select_path(&panel, "src/test", cx);
2550 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2551 cx.executor().run_until_parked();
2552 assert_eq!(
2553 visible_entries_as_strings(&panel, 0..10, cx),
2554 &[
2555 //
2556 "v src",
2557 " > test <== selected"
2558 ]
2559 );
2560 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2561 cx.run_until_parked();
2562 panel.update_in(cx, |panel, window, cx| {
2563 assert!(panel.filename_editor.read(cx).is_focused(window));
2564 });
2565 assert_eq!(
2566 visible_entries_as_strings(&panel, 0..10, cx),
2567 &[
2568 "v src",
2569 " v test",
2570 " [EDITOR: ''] <== selected",
2571 " first.rs",
2572 " second.rs",
2573 " third.rs"
2574 ]
2575 );
2576 panel.update_in(cx, |panel, window, cx| {
2577 panel
2578 .filename_editor
2579 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
2580 assert!(
2581 panel.confirm_edit(true, window, cx).is_none(),
2582 "Should not allow to confirm on conflicting new file name"
2583 );
2584 });
2585 cx.executor().run_until_parked();
2586 panel.update_in(cx, |panel, window, cx| {
2587 assert!(
2588 panel.state.edit_state.is_some(),
2589 "Edit state should not be None after conflicting new file name"
2590 );
2591 panel.cancel(&menu::Cancel, window, cx);
2592 });
2593 cx.run_until_parked();
2594 assert_eq!(
2595 visible_entries_as_strings(&panel, 0..10, cx),
2596 &[
2597 "v src",
2598 " v test <== selected",
2599 " first.rs",
2600 " second.rs",
2601 " third.rs"
2602 ],
2603 "File list should be unchanged after failed file create confirmation"
2604 );
2605
2606 select_path(&panel, "src/test/first.rs", cx);
2607 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2608 cx.executor().run_until_parked();
2609 assert_eq!(
2610 visible_entries_as_strings(&panel, 0..10, cx),
2611 &[
2612 "v src",
2613 " v test",
2614 " first.rs <== selected",
2615 " second.rs",
2616 " third.rs"
2617 ],
2618 );
2619 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2620 cx.executor().run_until_parked();
2621 panel.update_in(cx, |panel, window, cx| {
2622 assert!(panel.filename_editor.read(cx).is_focused(window));
2623 });
2624 assert_eq!(
2625 visible_entries_as_strings(&panel, 0..10, cx),
2626 &[
2627 "v src",
2628 " v test",
2629 " [EDITOR: 'first.rs'] <== selected",
2630 " second.rs",
2631 " third.rs"
2632 ]
2633 );
2634 panel.update_in(cx, |panel, window, cx| {
2635 panel
2636 .filename_editor
2637 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
2638 assert!(
2639 panel.confirm_edit(true, window, cx).is_none(),
2640 "Should not allow to confirm on conflicting file rename"
2641 )
2642 });
2643 cx.executor().run_until_parked();
2644 panel.update_in(cx, |panel, window, cx| {
2645 assert!(
2646 panel.state.edit_state.is_some(),
2647 "Edit state should not be None after conflicting file rename"
2648 );
2649 panel.cancel(&menu::Cancel, window, cx);
2650 });
2651 cx.executor().run_until_parked();
2652 assert_eq!(
2653 visible_entries_as_strings(&panel, 0..10, cx),
2654 &[
2655 "v src",
2656 " v test",
2657 " first.rs <== selected",
2658 " second.rs",
2659 " third.rs"
2660 ],
2661 "File list should be unchanged after failed rename confirmation"
2662 );
2663}
2664
2665// NOTE: This test is skipped on Windows, because on Windows,
2666// when it triggers the lsp store it converts `/src/test/first copy.txt` into an uri
2667// but it fails with message `"/src\\test\\first copy.txt" is not parseable as an URI`
2668#[gpui::test]
2669#[cfg_attr(target_os = "windows", ignore)]
2670async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppContext) {
2671 init_test_with_editor(cx);
2672
2673 let fs = FakeFs::new(cx.executor());
2674 fs.insert_tree(
2675 "/src",
2676 json!({
2677 "test": {
2678 "first.txt": "// First Txt file",
2679 "second.txt": "// Second Txt file",
2680 "third.txt": "// Third Txt file",
2681 }
2682 }),
2683 )
2684 .await;
2685
2686 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2687 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2688 let workspace = window
2689 .read_with(cx, |mw, _| mw.workspace().clone())
2690 .unwrap();
2691 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2692 let panel = workspace.update_in(cx, |workspace, window, cx| {
2693 let panel = ProjectPanel::new(workspace, window, cx);
2694 workspace.add_panel(panel.clone(), window, cx);
2695 panel
2696 });
2697 cx.run_until_parked();
2698
2699 select_path(&panel, "src", cx);
2700 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2701 cx.executor().run_until_parked();
2702 assert_eq!(
2703 visible_entries_as_strings(&panel, 0..10, cx),
2704 &[
2705 //
2706 "v src <== selected",
2707 " > test"
2708 ]
2709 );
2710 panel.update_in(cx, |panel, window, cx| {
2711 panel.new_directory(&NewDirectory, window, cx)
2712 });
2713 cx.run_until_parked();
2714 panel.update_in(cx, |panel, window, cx| {
2715 assert!(panel.filename_editor.read(cx).is_focused(window));
2716 });
2717 cx.executor().run_until_parked();
2718 assert_eq!(
2719 visible_entries_as_strings(&panel, 0..10, cx),
2720 &[
2721 //
2722 "v src",
2723 " > [EDITOR: ''] <== selected",
2724 " > test"
2725 ]
2726 );
2727 panel.update_in(cx, |panel, window, cx| {
2728 panel
2729 .filename_editor
2730 .update(cx, |editor, cx| editor.set_text("test", window, cx));
2731 assert!(
2732 panel.confirm_edit(true, window, cx).is_none(),
2733 "Should not allow to confirm on conflicting new directory name"
2734 );
2735 });
2736 cx.executor().run_until_parked();
2737 panel.update_in(cx, |panel, window, cx| {
2738 assert!(
2739 panel.state.edit_state.is_some(),
2740 "Edit state should not be None after conflicting new directory name"
2741 );
2742 panel.cancel(&menu::Cancel, window, cx);
2743 });
2744 cx.run_until_parked();
2745 assert_eq!(
2746 visible_entries_as_strings(&panel, 0..10, cx),
2747 &[
2748 //
2749 "v src <== selected",
2750 " > test"
2751 ],
2752 "File list should be unchanged after failed folder create confirmation"
2753 );
2754
2755 select_path(&panel, "src/test", cx);
2756 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2757 cx.executor().run_until_parked();
2758 assert_eq!(
2759 visible_entries_as_strings(&panel, 0..10, cx),
2760 &[
2761 //
2762 "v src",
2763 " > test <== selected"
2764 ]
2765 );
2766 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2767 cx.run_until_parked();
2768 panel.update_in(cx, |panel, window, cx| {
2769 assert!(panel.filename_editor.read(cx).is_focused(window));
2770 });
2771 assert_eq!(
2772 visible_entries_as_strings(&panel, 0..10, cx),
2773 &[
2774 "v src",
2775 " v test",
2776 " [EDITOR: ''] <== selected",
2777 " first.txt",
2778 " second.txt",
2779 " third.txt"
2780 ]
2781 );
2782 panel.update_in(cx, |panel, window, cx| {
2783 panel
2784 .filename_editor
2785 .update(cx, |editor, cx| editor.set_text("first.txt", window, cx));
2786 assert!(
2787 panel.confirm_edit(true, window, cx).is_none(),
2788 "Should not allow to confirm on conflicting new file name"
2789 );
2790 });
2791 cx.executor().run_until_parked();
2792 panel.update_in(cx, |panel, window, cx| {
2793 assert!(
2794 panel.state.edit_state.is_some(),
2795 "Edit state should not be None after conflicting new file name"
2796 );
2797 panel.cancel(&menu::Cancel, window, cx);
2798 });
2799 cx.run_until_parked();
2800 assert_eq!(
2801 visible_entries_as_strings(&panel, 0..10, cx),
2802 &[
2803 "v src",
2804 " v test <== selected",
2805 " first.txt",
2806 " second.txt",
2807 " third.txt"
2808 ],
2809 "File list should be unchanged after failed file create confirmation"
2810 );
2811
2812 select_path(&panel, "src/test/first.txt", cx);
2813 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2814 cx.executor().run_until_parked();
2815 assert_eq!(
2816 visible_entries_as_strings(&panel, 0..10, cx),
2817 &[
2818 "v src",
2819 " v test",
2820 " first.txt <== selected",
2821 " second.txt",
2822 " third.txt"
2823 ],
2824 );
2825 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2826 cx.executor().run_until_parked();
2827 panel.update_in(cx, |panel, window, cx| {
2828 assert!(panel.filename_editor.read(cx).is_focused(window));
2829 });
2830 assert_eq!(
2831 visible_entries_as_strings(&panel, 0..10, cx),
2832 &[
2833 "v src",
2834 " v test",
2835 " [EDITOR: 'first.txt'] <== selected",
2836 " second.txt",
2837 " third.txt"
2838 ]
2839 );
2840 panel.update_in(cx, |panel, window, cx| {
2841 panel
2842 .filename_editor
2843 .update(cx, |editor, cx| editor.set_text("second.txt", window, cx));
2844 assert!(
2845 panel.confirm_edit(true, window, cx).is_none(),
2846 "Should not allow to confirm on conflicting file rename"
2847 )
2848 });
2849 cx.executor().run_until_parked();
2850 panel.update_in(cx, |panel, window, cx| {
2851 assert!(
2852 panel.state.edit_state.is_some(),
2853 "Edit state should not be None after conflicting file rename"
2854 );
2855 panel.cancel(&menu::Cancel, window, cx);
2856 });
2857 cx.executor().run_until_parked();
2858 assert_eq!(
2859 visible_entries_as_strings(&panel, 0..10, cx),
2860 &[
2861 "v src",
2862 " v test",
2863 " first.txt <== selected",
2864 " second.txt",
2865 " third.txt"
2866 ],
2867 "File list should be unchanged after failed rename confirmation"
2868 );
2869 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2870 cx.executor().run_until_parked();
2871 // Try to duplicate and check history
2872 panel.update_in(cx, |panel, window, cx| {
2873 panel.duplicate(&Duplicate, window, cx)
2874 });
2875 cx.executor().run_until_parked();
2876
2877 assert_eq!(
2878 visible_entries_as_strings(&panel, 0..10, cx),
2879 &[
2880 "v src",
2881 " v test",
2882 " first.txt",
2883 " [EDITOR: 'first copy.txt'] <== selected <== marked",
2884 " second.txt",
2885 " third.txt"
2886 ],
2887 );
2888
2889 let confirm = panel.update_in(cx, |panel, window, cx| {
2890 panel
2891 .filename_editor
2892 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
2893 panel.confirm_edit(true, window, cx).unwrap()
2894 });
2895 confirm.await.unwrap();
2896 cx.executor().run_until_parked();
2897
2898 assert_eq!(
2899 visible_entries_as_strings(&panel, 0..10, cx),
2900 &[
2901 "v src",
2902 " v test",
2903 " first.txt",
2904 " fourth.txt <== selected",
2905 " second.txt",
2906 " third.txt"
2907 ],
2908 "File list should be different after rename confirmation"
2909 );
2910
2911 panel.update_in(cx, |panel, window, cx| {
2912 panel.update_visible_entries(None, false, false, window, cx);
2913 });
2914 cx.executor().run_until_parked();
2915
2916 select_path(&panel, "src/test/first.txt", cx);
2917 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2918 cx.executor().run_until_parked();
2919
2920 workspace.read_with(cx, |this, cx| {
2921 assert!(
2922 this.recent_navigation_history_iter(cx)
2923 .any(|(project_path, abs_path)| {
2924 project_path.path == Arc::from(rel_path("test/fourth.txt"))
2925 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
2926 })
2927 );
2928 });
2929}
2930
2931// NOTE: This test is skipped on Windows, because on Windows,
2932// when it triggers the lsp store it converts `/src/test/first.txt` into an uri
2933// but it fails with message `"/src\\test\\first.txt" is not parseable as an URI`
2934#[gpui::test]
2935#[cfg_attr(target_os = "windows", ignore)]
2936async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) {
2937 init_test_with_editor(cx);
2938
2939 let fs = FakeFs::new(cx.executor());
2940 fs.insert_tree(
2941 "/src",
2942 json!({
2943 "test": {
2944 "first.txt": "// First Txt file",
2945 "second.txt": "// Second Txt file",
2946 "third.txt": "// Third Txt file",
2947 }
2948 }),
2949 )
2950 .await;
2951
2952 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2953 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2954 let workspace = window
2955 .read_with(cx, |mw, _| mw.workspace().clone())
2956 .unwrap();
2957 let cx = &mut VisualTestContext::from_window(window.into(), cx);
2958 let panel = workspace.update_in(cx, |workspace, window, cx| {
2959 let panel = ProjectPanel::new(workspace, window, cx);
2960 workspace.add_panel(panel.clone(), window, cx);
2961 panel
2962 });
2963 cx.run_until_parked();
2964
2965 select_path(&panel, "src", cx);
2966 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2967 cx.executor().run_until_parked();
2968 assert_eq!(
2969 visible_entries_as_strings(&panel, 0..10, cx),
2970 &[
2971 //
2972 "v src <== selected",
2973 " > test"
2974 ]
2975 );
2976
2977 select_path(&panel, "src/test", cx);
2978 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
2979 cx.executor().run_until_parked();
2980 assert_eq!(
2981 visible_entries_as_strings(&panel, 0..10, cx),
2982 &[
2983 //
2984 "v src",
2985 " > test <== selected"
2986 ]
2987 );
2988 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
2989 cx.run_until_parked();
2990 panel.update_in(cx, |panel, window, cx| {
2991 assert!(panel.filename_editor.read(cx).is_focused(window));
2992 });
2993
2994 select_path(&panel, "src/test/first.txt", cx);
2995 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
2996 cx.executor().run_until_parked();
2997
2998 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
2999 cx.executor().run_until_parked();
3000
3001 assert_eq!(
3002 visible_entries_as_strings(&panel, 0..10, cx),
3003 &[
3004 "v src",
3005 " v test",
3006 " [EDITOR: 'first.txt'] <== selected <== marked",
3007 " second.txt",
3008 " third.txt"
3009 ],
3010 );
3011
3012 let confirm = panel.update_in(cx, |panel, window, cx| {
3013 panel
3014 .filename_editor
3015 .update(cx, |editor, cx| editor.set_text("fourth.txt", window, cx));
3016 panel.confirm_edit(true, window, cx).unwrap()
3017 });
3018 confirm.await.unwrap();
3019 cx.executor().run_until_parked();
3020
3021 assert_eq!(
3022 visible_entries_as_strings(&panel, 0..10, cx),
3023 &[
3024 "v src",
3025 " v test",
3026 " fourth.txt <== selected",
3027 " second.txt",
3028 " third.txt"
3029 ],
3030 "File list should be different after rename confirmation"
3031 );
3032
3033 panel.update_in(cx, |panel, window, cx| {
3034 panel.update_visible_entries(None, false, false, window, cx);
3035 });
3036 cx.executor().run_until_parked();
3037
3038 select_path(&panel, "src/test/second.txt", cx);
3039 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3040 cx.executor().run_until_parked();
3041
3042 workspace.read_with(cx, |this, cx| {
3043 assert!(
3044 this.recent_navigation_history_iter(cx)
3045 .any(|(project_path, abs_path)| {
3046 project_path.path == Arc::from(rel_path("test/fourth.txt"))
3047 && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt")))
3048 })
3049 );
3050 });
3051}
3052
3053#[gpui::test]
3054async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
3055 init_test_with_editor(cx);
3056
3057 let fs = FakeFs::new(cx.executor());
3058 fs.insert_tree(
3059 path!("/root"),
3060 json!({
3061 "tree1": {
3062 ".git": {},
3063 "dir1": {
3064 "modified1.txt": "1",
3065 "unmodified1.txt": "1",
3066 "modified2.txt": "1",
3067 },
3068 "dir2": {
3069 "modified3.txt": "1",
3070 "unmodified2.txt": "1",
3071 },
3072 "modified4.txt": "1",
3073 "unmodified3.txt": "1",
3074 },
3075 "tree2": {
3076 ".git": {},
3077 "dir3": {
3078 "modified5.txt": "1",
3079 "unmodified4.txt": "1",
3080 },
3081 "modified6.txt": "1",
3082 "unmodified5.txt": "1",
3083 }
3084 }),
3085 )
3086 .await;
3087
3088 // Mark files as git modified
3089 fs.set_head_and_index_for_repo(
3090 path!("/root/tree1/.git").as_ref(),
3091 &[
3092 ("dir1/modified1.txt", "modified".into()),
3093 ("dir1/modified2.txt", "modified".into()),
3094 ("modified4.txt", "modified".into()),
3095 ("dir2/modified3.txt", "modified".into()),
3096 ],
3097 );
3098 fs.set_head_and_index_for_repo(
3099 path!("/root/tree2/.git").as_ref(),
3100 &[
3101 ("dir3/modified5.txt", "modified".into()),
3102 ("modified6.txt", "modified".into()),
3103 ],
3104 );
3105
3106 let project = Project::test(
3107 fs.clone(),
3108 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
3109 cx,
3110 )
3111 .await;
3112
3113 let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
3114 let mut worktrees = project.worktrees(cx);
3115 let worktree1 = worktrees.next().unwrap();
3116 let worktree2 = worktrees.next().unwrap();
3117 (
3118 worktree1.read(cx).as_local().unwrap().scan_complete(),
3119 worktree2.read(cx).as_local().unwrap().scan_complete(),
3120 )
3121 });
3122 scan1_complete.await;
3123 scan2_complete.await;
3124 cx.run_until_parked();
3125
3126 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3127 let workspace = window
3128 .read_with(cx, |mw, _| mw.workspace().clone())
3129 .unwrap();
3130 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3131 let panel = workspace.update_in(cx, ProjectPanel::new);
3132 cx.run_until_parked();
3133
3134 // Check initial state
3135 assert_eq!(
3136 visible_entries_as_strings(&panel, 0..15, cx),
3137 &[
3138 "v tree1",
3139 " > .git",
3140 " > dir1",
3141 " > dir2",
3142 " modified4.txt",
3143 " unmodified3.txt",
3144 "v tree2",
3145 " > .git",
3146 " > dir3",
3147 " modified6.txt",
3148 " unmodified5.txt"
3149 ],
3150 );
3151
3152 // Test selecting next modified entry
3153 panel.update_in(cx, |panel, window, cx| {
3154 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3155 });
3156 cx.run_until_parked();
3157
3158 assert_eq!(
3159 visible_entries_as_strings(&panel, 0..6, cx),
3160 &[
3161 "v tree1",
3162 " > .git",
3163 " v dir1",
3164 " modified1.txt <== selected",
3165 " modified2.txt",
3166 " unmodified1.txt",
3167 ],
3168 );
3169
3170 panel.update_in(cx, |panel, window, cx| {
3171 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3172 });
3173 cx.run_until_parked();
3174
3175 assert_eq!(
3176 visible_entries_as_strings(&panel, 0..6, cx),
3177 &[
3178 "v tree1",
3179 " > .git",
3180 " v dir1",
3181 " modified1.txt",
3182 " modified2.txt <== selected",
3183 " unmodified1.txt",
3184 ],
3185 );
3186
3187 panel.update_in(cx, |panel, window, cx| {
3188 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3189 });
3190 cx.run_until_parked();
3191
3192 assert_eq!(
3193 visible_entries_as_strings(&panel, 6..9, cx),
3194 &[
3195 " v dir2",
3196 " modified3.txt <== selected",
3197 " unmodified2.txt",
3198 ],
3199 );
3200
3201 panel.update_in(cx, |panel, window, cx| {
3202 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3203 });
3204 cx.run_until_parked();
3205
3206 assert_eq!(
3207 visible_entries_as_strings(&panel, 9..11, cx),
3208 &[" modified4.txt <== selected", " unmodified3.txt",],
3209 );
3210
3211 panel.update_in(cx, |panel, window, cx| {
3212 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3213 });
3214 cx.run_until_parked();
3215
3216 assert_eq!(
3217 visible_entries_as_strings(&panel, 13..16, cx),
3218 &[
3219 " v dir3",
3220 " modified5.txt <== selected",
3221 " unmodified4.txt",
3222 ],
3223 );
3224
3225 panel.update_in(cx, |panel, window, cx| {
3226 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3227 });
3228 cx.run_until_parked();
3229
3230 assert_eq!(
3231 visible_entries_as_strings(&panel, 16..18, cx),
3232 &[" modified6.txt <== selected", " unmodified5.txt",],
3233 );
3234
3235 // Wraps around to first modified file
3236 panel.update_in(cx, |panel, window, cx| {
3237 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
3238 });
3239 cx.run_until_parked();
3240
3241 assert_eq!(
3242 visible_entries_as_strings(&panel, 0..18, cx),
3243 &[
3244 "v tree1",
3245 " > .git",
3246 " v dir1",
3247 " modified1.txt <== selected",
3248 " modified2.txt",
3249 " unmodified1.txt",
3250 " v dir2",
3251 " modified3.txt",
3252 " unmodified2.txt",
3253 " modified4.txt",
3254 " unmodified3.txt",
3255 "v tree2",
3256 " > .git",
3257 " v dir3",
3258 " modified5.txt",
3259 " unmodified4.txt",
3260 " modified6.txt",
3261 " unmodified5.txt",
3262 ],
3263 );
3264
3265 // Wraps around again to last modified file
3266 panel.update_in(cx, |panel, window, cx| {
3267 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3268 });
3269 cx.run_until_parked();
3270
3271 assert_eq!(
3272 visible_entries_as_strings(&panel, 16..18, cx),
3273 &[" modified6.txt <== selected", " unmodified5.txt",],
3274 );
3275
3276 panel.update_in(cx, |panel, window, cx| {
3277 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3278 });
3279 cx.run_until_parked();
3280
3281 assert_eq!(
3282 visible_entries_as_strings(&panel, 13..16, cx),
3283 &[
3284 " v dir3",
3285 " modified5.txt <== selected",
3286 " unmodified4.txt",
3287 ],
3288 );
3289
3290 panel.update_in(cx, |panel, window, cx| {
3291 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3292 });
3293 cx.run_until_parked();
3294
3295 assert_eq!(
3296 visible_entries_as_strings(&panel, 9..11, cx),
3297 &[" modified4.txt <== selected", " unmodified3.txt",],
3298 );
3299
3300 panel.update_in(cx, |panel, window, cx| {
3301 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3302 });
3303 cx.run_until_parked();
3304
3305 assert_eq!(
3306 visible_entries_as_strings(&panel, 6..9, cx),
3307 &[
3308 " v dir2",
3309 " modified3.txt <== selected",
3310 " unmodified2.txt",
3311 ],
3312 );
3313
3314 panel.update_in(cx, |panel, window, cx| {
3315 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3316 });
3317 cx.run_until_parked();
3318
3319 assert_eq!(
3320 visible_entries_as_strings(&panel, 0..6, cx),
3321 &[
3322 "v tree1",
3323 " > .git",
3324 " v dir1",
3325 " modified1.txt",
3326 " modified2.txt <== selected",
3327 " unmodified1.txt",
3328 ],
3329 );
3330
3331 panel.update_in(cx, |panel, window, cx| {
3332 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
3333 });
3334 cx.run_until_parked();
3335
3336 assert_eq!(
3337 visible_entries_as_strings(&panel, 0..6, cx),
3338 &[
3339 "v tree1",
3340 " > .git",
3341 " v dir1",
3342 " modified1.txt <== selected",
3343 " modified2.txt",
3344 " unmodified1.txt",
3345 ],
3346 );
3347}
3348
3349#[gpui::test]
3350async fn test_select_directory(cx: &mut gpui::TestAppContext) {
3351 init_test_with_editor(cx);
3352
3353 let fs = FakeFs::new(cx.executor());
3354 fs.insert_tree(
3355 "/project_root",
3356 json!({
3357 "dir_1": {
3358 "nested_dir": {
3359 "file_a.py": "# File contents",
3360 }
3361 },
3362 "file_1.py": "# File contents",
3363 "dir_2": {
3364
3365 },
3366 "dir_3": {
3367
3368 },
3369 "file_2.py": "# File contents",
3370 "dir_4": {
3371
3372 },
3373 }),
3374 )
3375 .await;
3376
3377 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3378 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3379 let workspace = window
3380 .read_with(cx, |mw, _| mw.workspace().clone())
3381 .unwrap();
3382 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3383 let panel = workspace.update_in(cx, ProjectPanel::new);
3384 cx.run_until_parked();
3385
3386 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3387 cx.executor().run_until_parked();
3388 select_path(&panel, "project_root/dir_1", cx);
3389 cx.executor().run_until_parked();
3390 assert_eq!(
3391 visible_entries_as_strings(&panel, 0..10, cx),
3392 &[
3393 "v project_root",
3394 " > dir_1 <== selected",
3395 " > dir_2",
3396 " > dir_3",
3397 " > dir_4",
3398 " file_1.py",
3399 " file_2.py",
3400 ]
3401 );
3402 panel.update_in(cx, |panel, window, cx| {
3403 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3404 });
3405
3406 assert_eq!(
3407 visible_entries_as_strings(&panel, 0..10, cx),
3408 &[
3409 "v project_root <== selected",
3410 " > dir_1",
3411 " > dir_2",
3412 " > dir_3",
3413 " > dir_4",
3414 " file_1.py",
3415 " file_2.py",
3416 ]
3417 );
3418
3419 panel.update_in(cx, |panel, window, cx| {
3420 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
3421 });
3422
3423 assert_eq!(
3424 visible_entries_as_strings(&panel, 0..10, cx),
3425 &[
3426 "v project_root",
3427 " > dir_1",
3428 " > dir_2",
3429 " > dir_3",
3430 " > dir_4 <== selected",
3431 " file_1.py",
3432 " file_2.py",
3433 ]
3434 );
3435
3436 panel.update_in(cx, |panel, window, cx| {
3437 panel.select_next_directory(&SelectNextDirectory, window, cx)
3438 });
3439
3440 assert_eq!(
3441 visible_entries_as_strings(&panel, 0..10, cx),
3442 &[
3443 "v project_root <== selected",
3444 " > dir_1",
3445 " > dir_2",
3446 " > dir_3",
3447 " > dir_4",
3448 " file_1.py",
3449 " file_2.py",
3450 ]
3451 );
3452}
3453
3454#[gpui::test]
3455async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
3456 init_test_with_editor(cx);
3457
3458 let fs = FakeFs::new(cx.executor());
3459 fs.insert_tree(
3460 "/project_root",
3461 json!({
3462 "dir_1": {
3463 "nested_dir": {
3464 "file_a.py": "# File contents",
3465 }
3466 },
3467 "file_1.py": "# File contents",
3468 "file_2.py": "# File contents",
3469 "zdir_2": {
3470 "nested_dir2": {
3471 "file_b.py": "# File contents",
3472 }
3473 },
3474 }),
3475 )
3476 .await;
3477
3478 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3479 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3480 let workspace = window
3481 .read_with(cx, |mw, _| mw.workspace().clone())
3482 .unwrap();
3483 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3484 let panel = workspace.update_in(cx, ProjectPanel::new);
3485 cx.run_until_parked();
3486
3487 assert_eq!(
3488 visible_entries_as_strings(&panel, 0..10, cx),
3489 &[
3490 "v project_root",
3491 " > dir_1",
3492 " > zdir_2",
3493 " file_1.py",
3494 " file_2.py",
3495 ]
3496 );
3497 panel.update_in(cx, |panel, window, cx| {
3498 panel.select_first(&SelectFirst, window, cx)
3499 });
3500
3501 assert_eq!(
3502 visible_entries_as_strings(&panel, 0..10, cx),
3503 &[
3504 "v project_root <== selected",
3505 " > dir_1",
3506 " > zdir_2",
3507 " file_1.py",
3508 " file_2.py",
3509 ]
3510 );
3511
3512 panel.update_in(cx, |panel, window, cx| {
3513 panel.select_last(&SelectLast, window, cx)
3514 });
3515
3516 assert_eq!(
3517 visible_entries_as_strings(&panel, 0..10, cx),
3518 &[
3519 "v project_root",
3520 " > dir_1",
3521 " > zdir_2",
3522 " file_1.py",
3523 " file_2.py <== selected",
3524 ]
3525 );
3526
3527 cx.update(|_, cx| {
3528 let settings = *ProjectPanelSettings::get_global(cx);
3529 ProjectPanelSettings::override_global(
3530 ProjectPanelSettings {
3531 hide_root: true,
3532 ..settings
3533 },
3534 cx,
3535 );
3536 });
3537
3538 let panel = workspace.update_in(cx, ProjectPanel::new);
3539 cx.run_until_parked();
3540
3541 #[rustfmt::skip]
3542 assert_eq!(
3543 visible_entries_as_strings(&panel, 0..10, cx),
3544 &[
3545 "> dir_1",
3546 "> zdir_2",
3547 " file_1.py",
3548 " file_2.py",
3549 ],
3550 "With hide_root=true, root should be hidden"
3551 );
3552
3553 panel.update_in(cx, |panel, window, cx| {
3554 panel.select_first(&SelectFirst, window, cx)
3555 });
3556
3557 assert_eq!(
3558 visible_entries_as_strings(&panel, 0..10, cx),
3559 &[
3560 "> dir_1 <== selected",
3561 "> zdir_2",
3562 " file_1.py",
3563 " file_2.py",
3564 ],
3565 "With hide_root=true, first entry should be dir_1, not the hidden root"
3566 );
3567}
3568
3569#[gpui::test]
3570async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3571 init_test_with_editor(cx);
3572
3573 let fs = FakeFs::new(cx.executor());
3574 fs.insert_tree(
3575 "/project_root",
3576 json!({
3577 "dir_1": {
3578 "nested_dir": {
3579 "file_a.py": "# File contents",
3580 }
3581 },
3582 "file_1.py": "# File contents",
3583 }),
3584 )
3585 .await;
3586
3587 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3588 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3589 let workspace = window
3590 .read_with(cx, |mw, _| mw.workspace().clone())
3591 .unwrap();
3592 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3593 let panel = workspace.update_in(cx, ProjectPanel::new);
3594 cx.run_until_parked();
3595
3596 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3597 cx.executor().run_until_parked();
3598 select_path(&panel, "project_root/dir_1", cx);
3599 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3600 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3601 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3602 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
3603 cx.executor().run_until_parked();
3604 assert_eq!(
3605 visible_entries_as_strings(&panel, 0..10, cx),
3606 &[
3607 "v project_root",
3608 " v dir_1",
3609 " > nested_dir <== selected",
3610 " file_1.py",
3611 ]
3612 );
3613}
3614
3615#[gpui::test]
3616async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3617 init_test_with_editor(cx);
3618
3619 let fs = FakeFs::new(cx.executor());
3620 fs.insert_tree(
3621 "/project_root",
3622 json!({
3623 "dir_1": {
3624 "nested_dir": {
3625 "file_a.py": "# File contents",
3626 "file_b.py": "# File contents",
3627 "file_c.py": "# File contents",
3628 },
3629 "file_1.py": "# File contents",
3630 "file_2.py": "# File contents",
3631 "file_3.py": "# File contents",
3632 },
3633 "dir_2": {
3634 "file_1.py": "# File contents",
3635 "file_2.py": "# File contents",
3636 "file_3.py": "# File contents",
3637 }
3638 }),
3639 )
3640 .await;
3641
3642 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3643 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3644 let workspace = window
3645 .read_with(cx, |mw, _| mw.workspace().clone())
3646 .unwrap();
3647 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3648 let panel = workspace.update_in(cx, ProjectPanel::new);
3649 cx.run_until_parked();
3650
3651 panel.update_in(cx, |panel, window, cx| {
3652 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3653 });
3654 cx.executor().run_until_parked();
3655 assert_eq!(
3656 visible_entries_as_strings(&panel, 0..10, cx),
3657 &["v project_root", " > dir_1", " > dir_2",]
3658 );
3659
3660 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3661 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3662 cx.executor().run_until_parked();
3663 assert_eq!(
3664 visible_entries_as_strings(&panel, 0..10, cx),
3665 &[
3666 "v project_root",
3667 " v dir_1 <== selected",
3668 " > nested_dir",
3669 " file_1.py",
3670 " file_2.py",
3671 " file_3.py",
3672 " > dir_2",
3673 ]
3674 );
3675}
3676
3677#[gpui::test]
3678async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppContext) {
3679 init_test_with_editor(cx);
3680
3681 let fs = FakeFs::new(cx.executor());
3682 let worktree_content = json!({
3683 "dir_1": {
3684 "file_1.py": "# File contents",
3685 },
3686 "dir_2": {
3687 "file_1.py": "# File contents",
3688 }
3689 });
3690
3691 fs.insert_tree("/project_root_1", worktree_content.clone())
3692 .await;
3693 fs.insert_tree("/project_root_2", worktree_content).await;
3694
3695 let project = Project::test(
3696 fs.clone(),
3697 ["/project_root_1".as_ref(), "/project_root_2".as_ref()],
3698 cx,
3699 )
3700 .await;
3701 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3702 let workspace = window
3703 .read_with(cx, |mw, _| mw.workspace().clone())
3704 .unwrap();
3705 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3706 let panel = workspace.update_in(cx, ProjectPanel::new);
3707 cx.run_until_parked();
3708
3709 panel.update_in(cx, |panel, window, cx| {
3710 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3711 });
3712 cx.executor().run_until_parked();
3713 assert_eq!(
3714 visible_entries_as_strings(&panel, 0..10, cx),
3715 &["> project_root_1", "> project_root_2",]
3716 );
3717}
3718
3719#[gpui::test]
3720async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppContext) {
3721 init_test_with_editor(cx);
3722
3723 let fs = FakeFs::new(cx.executor());
3724 fs.insert_tree(
3725 "/project_root",
3726 json!({
3727 "dir_1": {
3728 "nested_dir": {
3729 "file_a.py": "# File contents",
3730 "file_b.py": "# File contents",
3731 "file_c.py": "# File contents",
3732 },
3733 "file_1.py": "# File contents",
3734 "file_2.py": "# File contents",
3735 "file_3.py": "# File contents",
3736 },
3737 "dir_2": {
3738 "file_1.py": "# File contents",
3739 "file_2.py": "# File contents",
3740 "file_3.py": "# File contents",
3741 }
3742 }),
3743 )
3744 .await;
3745
3746 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3747 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3748 let workspace = window
3749 .read_with(cx, |mw, _| mw.workspace().clone())
3750 .unwrap();
3751 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3752 let panel = workspace.update_in(cx, ProjectPanel::new);
3753 cx.run_until_parked();
3754
3755 // Open project_root/dir_1 to ensure that a nested directory is expanded
3756 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3757 cx.executor().run_until_parked();
3758 assert_eq!(
3759 visible_entries_as_strings(&panel, 0..10, cx),
3760 &[
3761 "v project_root",
3762 " v dir_1 <== selected",
3763 " > nested_dir",
3764 " file_1.py",
3765 " file_2.py",
3766 " file_3.py",
3767 " > dir_2",
3768 ]
3769 );
3770
3771 // Close root directory
3772 toggle_expand_dir(&panel, "project_root", cx);
3773 cx.executor().run_until_parked();
3774 assert_eq!(
3775 visible_entries_as_strings(&panel, 0..10, cx),
3776 &["> project_root <== selected"]
3777 );
3778
3779 // Run collapse_all_entries and make sure root is not expanded
3780 panel.update_in(cx, |panel, window, cx| {
3781 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3782 });
3783 cx.executor().run_until_parked();
3784 assert_eq!(
3785 visible_entries_as_strings(&panel, 0..10, cx),
3786 &["> project_root <== selected"]
3787 );
3788}
3789
3790#[gpui::test]
3791async fn test_collapse_all_entries_with_invisible_worktree(cx: &mut gpui::TestAppContext) {
3792 init_test_with_editor(cx);
3793
3794 let fs = FakeFs::new(cx.executor());
3795 fs.insert_tree(
3796 "/project_root",
3797 json!({
3798 "dir_1": {
3799 "nested_dir": {
3800 "file_a.py": "# File contents",
3801 },
3802 "file_1.py": "# File contents",
3803 },
3804 "dir_2": {
3805 "file_1.py": "# File contents",
3806 }
3807 }),
3808 )
3809 .await;
3810 fs.insert_tree(
3811 "/external",
3812 json!({
3813 "external_file.py": "# External file",
3814 }),
3815 )
3816 .await;
3817
3818 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3819 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3820 let workspace = window
3821 .read_with(cx, |mw, _| mw.workspace().clone())
3822 .unwrap();
3823 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3824 let panel = workspace.update_in(cx, ProjectPanel::new);
3825 cx.run_until_parked();
3826
3827 let (_invisible_worktree, _) = project
3828 .update(cx, |project, cx| {
3829 project.find_or_create_worktree("/external/external_file.py", false, cx)
3830 })
3831 .await
3832 .unwrap();
3833 cx.run_until_parked();
3834
3835 assert_eq!(
3836 visible_entries_as_strings(&panel, 0..10, cx),
3837 &["v project_root", " > dir_1", " > dir_2",],
3838 "invisible worktree should not appear in project panel"
3839 );
3840
3841 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3842 cx.executor().run_until_parked();
3843
3844 panel.update_in(cx, |panel, window, cx| {
3845 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
3846 });
3847 cx.executor().run_until_parked();
3848 assert_eq!(
3849 visible_entries_as_strings(&panel, 0..10, cx),
3850 &["v project_root", " > dir_1 <== selected", " > dir_2",],
3851 "with single visible worktree, root should stay expanded even if invisible worktrees exist"
3852 );
3853}
3854
3855#[gpui::test]
3856async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3857 init_test(cx);
3858
3859 let fs = FakeFs::new(cx.executor());
3860 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
3861 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
3862 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3863 let workspace = window
3864 .read_with(cx, |mw, _| mw.workspace().clone())
3865 .unwrap();
3866 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3867 let panel = workspace.update_in(cx, ProjectPanel::new);
3868 cx.run_until_parked();
3869
3870 // Make a new buffer with no backing file
3871 workspace.update_in(cx, |workspace, window, cx| {
3872 Editor::new_file(workspace, &Default::default(), window, cx)
3873 });
3874
3875 cx.executor().run_until_parked();
3876
3877 // "Save as" the buffer, creating a new backing file for it
3878 let save_task = workspace.update_in(cx, |workspace, window, cx| {
3879 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3880 });
3881
3882 cx.executor().run_until_parked();
3883 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
3884 save_task.await.unwrap();
3885
3886 // Rename the file
3887 select_path(&panel, "root/new", cx);
3888 assert_eq!(
3889 visible_entries_as_strings(&panel, 0..10, cx),
3890 &["v root", " new <== selected <== marked"]
3891 );
3892 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3893 panel.update_in(cx, |panel, window, cx| {
3894 panel
3895 .filename_editor
3896 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
3897 });
3898 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
3899
3900 cx.executor().run_until_parked();
3901 assert_eq!(
3902 visible_entries_as_strings(&panel, 0..10, cx),
3903 &["v root", " newer <== selected"]
3904 );
3905
3906 workspace
3907 .update_in(cx, |workspace, window, cx| {
3908 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
3909 })
3910 .await
3911 .unwrap();
3912
3913 cx.executor().run_until_parked();
3914 // assert that saving the file doesn't restore "new"
3915 assert_eq!(
3916 visible_entries_as_strings(&panel, 0..10, cx),
3917 &["v root", " newer <== selected"]
3918 );
3919}
3920
3921// NOTE: This test is skipped on Windows, because on Windows, unlike on Unix,
3922// you can't rename a directory which some program has already open. This is a
3923// limitation of the Windows. Since Zed will have the root open, it will hold an open handle
3924// to it, and thus renaming it will fail on Windows.
3925// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
3926// See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information
3927#[gpui::test]
3928#[cfg_attr(target_os = "windows", ignore)]
3929async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
3930 init_test_with_editor(cx);
3931
3932 let fs = FakeFs::new(cx.executor());
3933 fs.insert_tree(
3934 "/root1",
3935 json!({
3936 "dir1": {
3937 "file1.txt": "content 1",
3938 },
3939 }),
3940 )
3941 .await;
3942
3943 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3944 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3945 let workspace = window
3946 .read_with(cx, |mw, _| mw.workspace().clone())
3947 .unwrap();
3948 let cx = &mut VisualTestContext::from_window(window.into(), cx);
3949 let panel = workspace.update_in(cx, ProjectPanel::new);
3950 cx.run_until_parked();
3951
3952 toggle_expand_dir(&panel, "root1/dir1", cx);
3953
3954 assert_eq!(
3955 visible_entries_as_strings(&panel, 0..20, cx),
3956 &["v root1", " v dir1 <== selected", " file1.txt",],
3957 "Initial state with worktrees"
3958 );
3959
3960 select_path(&panel, "root1", cx);
3961 assert_eq!(
3962 visible_entries_as_strings(&panel, 0..20, cx),
3963 &["v root1 <== selected", " v dir1", " file1.txt",],
3964 );
3965
3966 // Rename root1 to new_root1
3967 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
3968
3969 assert_eq!(
3970 visible_entries_as_strings(&panel, 0..20, cx),
3971 &[
3972 "v [EDITOR: 'root1'] <== selected",
3973 " v dir1",
3974 " file1.txt",
3975 ],
3976 );
3977
3978 let confirm = panel.update_in(cx, |panel, window, cx| {
3979 panel
3980 .filename_editor
3981 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
3982 panel.confirm_edit(true, window, cx).unwrap()
3983 });
3984 confirm.await.unwrap();
3985 cx.run_until_parked();
3986 assert_eq!(
3987 visible_entries_as_strings(&panel, 0..20, cx),
3988 &[
3989 "v new_root1 <== selected",
3990 " v dir1",
3991 " file1.txt",
3992 ],
3993 "Should update worktree name"
3994 );
3995
3996 // Ensure internal paths have been updated
3997 select_path(&panel, "new_root1/dir1/file1.txt", cx);
3998 assert_eq!(
3999 visible_entries_as_strings(&panel, 0..20, cx),
4000 &[
4001 "v new_root1",
4002 " v dir1",
4003 " file1.txt <== selected",
4004 ],
4005 "Files in renamed worktree are selectable"
4006 );
4007}
4008
4009#[gpui::test]
4010async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) {
4011 init_test_with_editor(cx);
4012
4013 let fs = FakeFs::new(cx.executor());
4014 fs.insert_tree(
4015 "/root1",
4016 json!({
4017 "dir1": { "file1.txt": "content" },
4018 "file2.txt": "content",
4019 }),
4020 )
4021 .await;
4022 fs.insert_tree("/root2", json!({ "file3.txt": "content" }))
4023 .await;
4024
4025 // Test 1: Single worktree, hide_root=true - rename should be blocked
4026 {
4027 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4028 let window =
4029 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4030 let workspace = window
4031 .read_with(cx, |mw, _| mw.workspace().clone())
4032 .unwrap();
4033 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4034
4035 cx.update(|_, cx| {
4036 let settings = *ProjectPanelSettings::get_global(cx);
4037 ProjectPanelSettings::override_global(
4038 ProjectPanelSettings {
4039 hide_root: true,
4040 ..settings
4041 },
4042 cx,
4043 );
4044 });
4045
4046 let panel = workspace.update_in(cx, ProjectPanel::new);
4047 cx.run_until_parked();
4048
4049 panel.update(cx, |panel, cx| {
4050 let project = panel.project.read(cx);
4051 let worktree = project.visible_worktrees(cx).next().unwrap();
4052 let root_entry = worktree.read(cx).root_entry().unwrap();
4053 panel.selection = Some(SelectedEntry {
4054 worktree_id: worktree.read(cx).id(),
4055 entry_id: root_entry.id,
4056 });
4057 });
4058
4059 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4060
4061 assert!(
4062 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
4063 "Rename should be blocked when hide_root=true with single worktree"
4064 );
4065 }
4066
4067 // Test 2: Multiple worktrees, hide_root=true - rename should work
4068 {
4069 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4070 let window =
4071 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4072 let workspace = window
4073 .read_with(cx, |mw, _| mw.workspace().clone())
4074 .unwrap();
4075 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4076
4077 cx.update(|_, cx| {
4078 let settings = *ProjectPanelSettings::get_global(cx);
4079 ProjectPanelSettings::override_global(
4080 ProjectPanelSettings {
4081 hide_root: true,
4082 ..settings
4083 },
4084 cx,
4085 );
4086 });
4087
4088 let panel = workspace.update_in(cx, ProjectPanel::new);
4089 cx.run_until_parked();
4090
4091 select_path(&panel, "root1", cx);
4092 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
4093
4094 #[cfg(target_os = "windows")]
4095 assert!(
4096 panel.read_with(cx, |panel, _| panel.state.edit_state.is_none()),
4097 "Rename should be blocked on Windows even with multiple worktrees"
4098 );
4099
4100 #[cfg(not(target_os = "windows"))]
4101 {
4102 assert!(
4103 panel.read_with(cx, |panel, _| panel.state.edit_state.is_some()),
4104 "Rename should work with multiple worktrees on non-Windows when hide_root=true"
4105 );
4106 panel.update_in(cx, |panel, window, cx| {
4107 panel.cancel(&menu::Cancel, window, cx)
4108 });
4109 }
4110 }
4111}
4112
4113#[gpui::test]
4114async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4115 init_test_with_editor(cx);
4116 let fs = FakeFs::new(cx.executor());
4117 fs.insert_tree(
4118 "/project_root",
4119 json!({
4120 "dir_1": {
4121 "nested_dir": {
4122 "file_a.py": "# File contents",
4123 }
4124 },
4125 "file_1.py": "# File contents",
4126 }),
4127 )
4128 .await;
4129
4130 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4131 let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
4132 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4133 let workspace = window
4134 .read_with(cx, |mw, _| mw.workspace().clone())
4135 .unwrap();
4136 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4137 let panel = workspace.update_in(cx, ProjectPanel::new);
4138 cx.run_until_parked();
4139
4140 cx.update(|window, cx| {
4141 panel.update(cx, |this, cx| {
4142 this.select_next(&Default::default(), window, cx);
4143 this.expand_selected_entry(&Default::default(), window, cx);
4144 })
4145 });
4146 cx.run_until_parked();
4147
4148 cx.update(|window, cx| {
4149 panel.update(cx, |this, cx| {
4150 this.expand_selected_entry(&Default::default(), window, cx);
4151 })
4152 });
4153 cx.run_until_parked();
4154
4155 cx.update(|window, cx| {
4156 panel.update(cx, |this, cx| {
4157 this.select_next(&Default::default(), window, cx);
4158 this.expand_selected_entry(&Default::default(), window, cx);
4159 })
4160 });
4161 cx.run_until_parked();
4162
4163 cx.update(|window, cx| {
4164 panel.update(cx, |this, cx| {
4165 this.select_next(&Default::default(), window, cx);
4166 })
4167 });
4168 cx.run_until_parked();
4169
4170 assert_eq!(
4171 visible_entries_as_strings(&panel, 0..10, cx),
4172 &[
4173 "v project_root",
4174 " v dir_1",
4175 " v nested_dir",
4176 " file_a.py <== selected",
4177 " file_1.py",
4178 ]
4179 );
4180 let modifiers_with_shift = gpui::Modifiers {
4181 shift: true,
4182 ..Default::default()
4183 };
4184 cx.run_until_parked();
4185 cx.simulate_modifiers_change(modifiers_with_shift);
4186 cx.update(|window, cx| {
4187 panel.update(cx, |this, cx| {
4188 this.select_next(&Default::default(), window, cx);
4189 })
4190 });
4191 assert_eq!(
4192 visible_entries_as_strings(&panel, 0..10, cx),
4193 &[
4194 "v project_root",
4195 " v dir_1",
4196 " v nested_dir",
4197 " file_a.py",
4198 " file_1.py <== selected <== marked",
4199 ]
4200 );
4201 cx.update(|window, cx| {
4202 panel.update(cx, |this, cx| {
4203 this.select_previous(&Default::default(), window, cx);
4204 })
4205 });
4206 assert_eq!(
4207 visible_entries_as_strings(&panel, 0..10, cx),
4208 &[
4209 "v project_root",
4210 " v dir_1",
4211 " v nested_dir",
4212 " file_a.py <== selected <== marked",
4213 " file_1.py <== marked",
4214 ]
4215 );
4216 cx.update(|window, cx| {
4217 panel.update(cx, |this, cx| {
4218 let drag = DraggedSelection {
4219 active_selection: this.selection.unwrap(),
4220 marked_selections: this.marked_entries.clone().into(),
4221 };
4222 let target_entry = this
4223 .project
4224 .read(cx)
4225 .entry_for_path(&(worktree_id, rel_path("")).into(), cx)
4226 .unwrap();
4227 this.drag_onto(&drag, target_entry.id, false, window, cx);
4228 });
4229 });
4230 cx.run_until_parked();
4231 assert_eq!(
4232 visible_entries_as_strings(&panel, 0..10, cx),
4233 &[
4234 "v project_root",
4235 " v dir_1",
4236 " v nested_dir",
4237 " file_1.py <== marked",
4238 " file_a.py <== selected <== marked",
4239 ]
4240 );
4241 // ESC clears out all marks
4242 cx.update(|window, cx| {
4243 panel.update(cx, |this, cx| {
4244 this.cancel(&menu::Cancel, window, cx);
4245 })
4246 });
4247 cx.executor().run_until_parked();
4248 assert_eq!(
4249 visible_entries_as_strings(&panel, 0..10, cx),
4250 &[
4251 "v project_root",
4252 " v dir_1",
4253 " v nested_dir",
4254 " file_1.py",
4255 " file_a.py <== selected",
4256 ]
4257 );
4258 // ESC clears out all marks
4259 cx.update(|window, cx| {
4260 panel.update(cx, |this, cx| {
4261 this.select_previous(&SelectPrevious, window, cx);
4262 this.select_next(&SelectNext, window, cx);
4263 })
4264 });
4265 assert_eq!(
4266 visible_entries_as_strings(&panel, 0..10, cx),
4267 &[
4268 "v project_root",
4269 " v dir_1",
4270 " v nested_dir",
4271 " file_1.py <== marked",
4272 " file_a.py <== selected <== marked",
4273 ]
4274 );
4275 cx.simulate_modifiers_change(Default::default());
4276 cx.update(|window, cx| {
4277 panel.update(cx, |this, cx| {
4278 this.cut(&Cut, window, cx);
4279 this.select_previous(&SelectPrevious, window, cx);
4280 this.select_previous(&SelectPrevious, window, cx);
4281
4282 this.paste(&Paste, window, cx);
4283 this.update_visible_entries(None, false, false, window, cx);
4284 })
4285 });
4286 cx.run_until_parked();
4287 assert_eq!(
4288 visible_entries_as_strings(&panel, 0..10, cx),
4289 &[
4290 "v project_root",
4291 " v dir_1",
4292 " v nested_dir",
4293 " file_1.py <== marked",
4294 " file_a.py <== selected <== marked",
4295 ]
4296 );
4297 cx.simulate_modifiers_change(modifiers_with_shift);
4298 cx.update(|window, cx| {
4299 panel.update(cx, |this, cx| {
4300 this.expand_selected_entry(&Default::default(), window, cx);
4301 this.select_next(&SelectNext, window, cx);
4302 this.select_next(&SelectNext, window, cx);
4303 })
4304 });
4305 submit_deletion(&panel, cx);
4306 assert_eq!(
4307 visible_entries_as_strings(&panel, 0..10, cx),
4308 &[
4309 "v project_root",
4310 " v dir_1",
4311 " v nested_dir <== selected",
4312 ]
4313 );
4314}
4315
4316#[gpui::test]
4317async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) {
4318 init_test(cx);
4319
4320 let fs = FakeFs::new(cx.executor());
4321 fs.insert_tree(
4322 "/root",
4323 json!({
4324 "a": {
4325 "b": {
4326 "c": {
4327 "d": {}
4328 }
4329 }
4330 },
4331 "target_destination": {}
4332 }),
4333 )
4334 .await;
4335
4336 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4337 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4338 let workspace = window
4339 .read_with(cx, |mw, _| mw.workspace().clone())
4340 .unwrap();
4341 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4342
4343 cx.update(|_, cx| {
4344 let settings = *ProjectPanelSettings::get_global(cx);
4345 ProjectPanelSettings::override_global(
4346 ProjectPanelSettings {
4347 auto_fold_dirs: true,
4348 ..settings
4349 },
4350 cx,
4351 );
4352 });
4353
4354 let panel = workspace.update_in(cx, ProjectPanel::new);
4355 cx.run_until_parked();
4356
4357 // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c'
4358 select_path(&panel, "root/a/b/c/d", cx);
4359 panel.update_in(cx, |panel, window, cx| {
4360 let drag = DraggedSelection {
4361 active_selection: *panel.selection.as_ref().unwrap(),
4362 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4363 };
4364 let target_entry = panel
4365 .project
4366 .read(cx)
4367 .visible_worktrees(cx)
4368 .next()
4369 .unwrap()
4370 .read(cx)
4371 .entry_for_path(rel_path("target_destination"))
4372 .unwrap();
4373 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4374 });
4375 cx.executor().run_until_parked();
4376
4377 assert_eq!(
4378 visible_entries_as_strings(&panel, 0..10, cx),
4379 &[
4380 "v root",
4381 " > a/b/c",
4382 " > target_destination/d <== selected"
4383 ],
4384 "Moving last empty directory 'd' should leave 'a/b/c' and move only 'd'"
4385 );
4386
4387 // Reset
4388 select_path(&panel, "root/target_destination/d", cx);
4389 panel.update_in(cx, |panel, window, cx| {
4390 let drag = DraggedSelection {
4391 active_selection: *panel.selection.as_ref().unwrap(),
4392 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4393 };
4394 let target_entry = panel
4395 .project
4396 .read(cx)
4397 .visible_worktrees(cx)
4398 .next()
4399 .unwrap()
4400 .read(cx)
4401 .entry_for_path(rel_path("a/b/c"))
4402 .unwrap();
4403 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4404 });
4405 cx.executor().run_until_parked();
4406
4407 // Case 2: Move middle dir 'b' - should move 'b/c/d', leaving only 'a'
4408 select_path(&panel, "root/a/b", cx);
4409 panel.update_in(cx, |panel, window, cx| {
4410 let drag = DraggedSelection {
4411 active_selection: *panel.selection.as_ref().unwrap(),
4412 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4413 };
4414 let target_entry = panel
4415 .project
4416 .read(cx)
4417 .visible_worktrees(cx)
4418 .next()
4419 .unwrap()
4420 .read(cx)
4421 .entry_for_path(rel_path("target_destination"))
4422 .unwrap();
4423 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4424 });
4425 cx.executor().run_until_parked();
4426
4427 assert_eq!(
4428 visible_entries_as_strings(&panel, 0..10, cx),
4429 &["v root", " v a", " > target_destination/b/c/d"],
4430 "Moving middle directory 'b' should leave only 'a' and move 'b/c/d'"
4431 );
4432
4433 // Reset
4434 select_path(&panel, "root/target_destination/b", cx);
4435 panel.update_in(cx, |panel, window, cx| {
4436 let drag = DraggedSelection {
4437 active_selection: *panel.selection.as_ref().unwrap(),
4438 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4439 };
4440 let target_entry = panel
4441 .project
4442 .read(cx)
4443 .visible_worktrees(cx)
4444 .next()
4445 .unwrap()
4446 .read(cx)
4447 .entry_for_path(rel_path("a"))
4448 .unwrap();
4449 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4450 });
4451 cx.executor().run_until_parked();
4452
4453 // Case 3: Move first dir 'a' - should move whole 'a/b/c/d'
4454 select_path(&panel, "root/a", cx);
4455 panel.update_in(cx, |panel, window, cx| {
4456 let drag = DraggedSelection {
4457 active_selection: *panel.selection.as_ref().unwrap(),
4458 marked_selections: Arc::new([*panel.selection.as_ref().unwrap()]),
4459 };
4460 let target_entry = panel
4461 .project
4462 .read(cx)
4463 .visible_worktrees(cx)
4464 .next()
4465 .unwrap()
4466 .read(cx)
4467 .entry_for_path(rel_path("target_destination"))
4468 .unwrap();
4469 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4470 });
4471 cx.executor().run_until_parked();
4472
4473 assert_eq!(
4474 visible_entries_as_strings(&panel, 0..10, cx),
4475 &["v root", " > target_destination/a/b/c/d"],
4476 "Moving first directory 'a' should move whole 'a/b/c/d' chain"
4477 );
4478}
4479
4480#[gpui::test]
4481async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppContext) {
4482 init_test(cx);
4483
4484 let fs = FakeFs::new(cx.executor());
4485 fs.insert_tree(
4486 "/root",
4487 json!({
4488 "a": {
4489 "b": {
4490 "c": {}
4491 }
4492 },
4493 "e": {
4494 "f": {
4495 "g": {}
4496 }
4497 },
4498 "target": {}
4499 }),
4500 )
4501 .await;
4502
4503 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4504 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4505 let workspace = window
4506 .read_with(cx, |mw, _| mw.workspace().clone())
4507 .unwrap();
4508 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4509
4510 cx.update(|_, cx| {
4511 let settings = *ProjectPanelSettings::get_global(cx);
4512 ProjectPanelSettings::override_global(
4513 ProjectPanelSettings {
4514 auto_fold_dirs: true,
4515 ..settings
4516 },
4517 cx,
4518 );
4519 });
4520
4521 let panel = workspace.update_in(cx, ProjectPanel::new);
4522 cx.run_until_parked();
4523
4524 assert_eq!(
4525 visible_entries_as_strings(&panel, 0..10, cx),
4526 &["v root", " > a/b/c", " > e/f/g", " > target"]
4527 );
4528
4529 select_folded_path_with_mark(&panel, "root/a/b/c", "root/a/b", cx);
4530 select_folded_path_with_mark(&panel, "root/e/f/g", "root/e/f", cx);
4531
4532 panel.update_in(cx, |panel, window, cx| {
4533 let drag = DraggedSelection {
4534 active_selection: *panel.selection.as_ref().unwrap(),
4535 marked_selections: panel.marked_entries.clone().into(),
4536 };
4537 let target_entry = panel
4538 .project
4539 .read(cx)
4540 .visible_worktrees(cx)
4541 .next()
4542 .unwrap()
4543 .read(cx)
4544 .entry_for_path(rel_path("target"))
4545 .unwrap();
4546 panel.drag_onto(&drag, target_entry.id, false, window, cx);
4547 });
4548 cx.executor().run_until_parked();
4549
4550 // After dragging 'b/c' and 'f/g' should be moved to target
4551 assert_eq!(
4552 visible_entries_as_strings(&panel, 0..10, cx),
4553 &[
4554 "v root",
4555 " > a",
4556 " > e",
4557 " v target",
4558 " > b/c",
4559 " > f/g <== selected <== marked"
4560 ],
4561 "Should move 'b/c' and 'f/g' to target, leaving 'a' and 'e'"
4562 );
4563}
4564
4565#[gpui::test]
4566async fn test_dragging_same_named_files_preserves_one_source_on_conflict(
4567 cx: &mut gpui::TestAppContext,
4568) {
4569 init_test(cx);
4570
4571 let fs = FakeFs::new(cx.executor());
4572 fs.insert_tree(
4573 "/root",
4574 json!({
4575 "dir_a": {
4576 "shared.txt": "from a"
4577 },
4578 "dir_b": {
4579 "shared.txt": "from b"
4580 }
4581 }),
4582 )
4583 .await;
4584
4585 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4586 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4587 let workspace = window
4588 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
4589 .unwrap();
4590 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4591 let panel = workspace.update_in(cx, ProjectPanel::new);
4592 cx.run_until_parked();
4593
4594 panel.update_in(cx, |panel, window, cx| {
4595 let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = {
4596 let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap();
4597 let worktree = worktree.read(cx);
4598 let root_entry_id = worktree.root_entry().unwrap().id;
4599 let worktree_id = worktree.id();
4600 let entry_a_id = worktree
4601 .entry_for_path(rel_path("dir_a/shared.txt"))
4602 .unwrap()
4603 .id;
4604 let entry_b_id = worktree
4605 .entry_for_path(rel_path("dir_b/shared.txt"))
4606 .unwrap()
4607 .id;
4608 (root_entry_id, worktree_id, entry_a_id, entry_b_id)
4609 };
4610
4611 let drag = DraggedSelection {
4612 active_selection: SelectedEntry {
4613 worktree_id,
4614 entry_id: entry_a_id,
4615 },
4616 marked_selections: Arc::new([
4617 SelectedEntry {
4618 worktree_id,
4619 entry_id: entry_a_id,
4620 },
4621 SelectedEntry {
4622 worktree_id,
4623 entry_id: entry_b_id,
4624 },
4625 ]),
4626 };
4627
4628 panel.drag_onto(&drag, root_entry_id, false, window, cx);
4629 });
4630 cx.executor().run_until_parked();
4631
4632 let files = fs.files();
4633 assert!(files.contains(&PathBuf::from(path!("/root/shared.txt"))));
4634
4635 let remaining_sources = [
4636 PathBuf::from(path!("/root/dir_a/shared.txt")),
4637 PathBuf::from(path!("/root/dir_b/shared.txt")),
4638 ]
4639 .into_iter()
4640 .filter(|path| files.contains(path))
4641 .count();
4642
4643 assert_eq!(
4644 remaining_sources, 1,
4645 "one conflicting source file should remain in place"
4646 );
4647}
4648
4649#[gpui::test]
4650async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4651 init_test(cx);
4652
4653 let fs = FakeFs::new(cx.executor());
4654 fs.insert_tree(
4655 "/root_a",
4656 json!({
4657 "src": {
4658 "lib.rs": "",
4659 "main.rs": ""
4660 },
4661 "docs": {
4662 "guide.md": ""
4663 },
4664 "multi": {
4665 "alpha.txt": "",
4666 "beta.txt": ""
4667 }
4668 }),
4669 )
4670 .await;
4671 fs.insert_tree(
4672 "/root_b",
4673 json!({
4674 "dst": {
4675 "existing.md": ""
4676 },
4677 "target.txt": ""
4678 }),
4679 )
4680 .await;
4681
4682 let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
4683 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4684 let workspace = window
4685 .read_with(cx, |mw, _| mw.workspace().clone())
4686 .unwrap();
4687 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4688 let panel = workspace.update_in(cx, ProjectPanel::new);
4689 cx.run_until_parked();
4690
4691 // Case 1: move a file onto a directory in another worktree.
4692 select_path(&panel, "root_a/src/main.rs", cx);
4693 drag_selection_to(&panel, "root_b/dst", false, cx);
4694 assert!(
4695 find_project_entry(&panel, "root_b/dst/main.rs", cx).is_some(),
4696 "Dragged file should appear under destination worktree"
4697 );
4698 assert_eq!(
4699 find_project_entry(&panel, "root_a/src/main.rs", cx),
4700 None,
4701 "Dragged file should be removed from the source worktree"
4702 );
4703
4704 // Case 2: drop a file onto another worktree file so it lands in the parent directory.
4705 select_path(&panel, "root_a/docs/guide.md", cx);
4706 drag_selection_to(&panel, "root_b/dst/existing.md", true, cx);
4707 assert!(
4708 find_project_entry(&panel, "root_b/dst/guide.md", cx).is_some(),
4709 "Dropping onto a file should place the entry beside the target file"
4710 );
4711 assert_eq!(
4712 find_project_entry(&panel, "root_a/docs/guide.md", cx),
4713 None,
4714 "Source file should be removed after the move"
4715 );
4716
4717 // Case 3: move an entire directory.
4718 select_path(&panel, "root_a/src", cx);
4719 drag_selection_to(&panel, "root_b/dst", false, cx);
4720 assert!(
4721 find_project_entry(&panel, "root_b/dst/src/lib.rs", cx).is_some(),
4722 "Dragging a directory should move its nested contents"
4723 );
4724 assert_eq!(
4725 find_project_entry(&panel, "root_a/src", cx),
4726 None,
4727 "Directory should no longer exist in the source worktree"
4728 );
4729
4730 // Case 4: multi-selection drag between worktrees.
4731 panel.update(cx, |panel, _| panel.marked_entries.clear());
4732 select_path_with_mark(&panel, "root_a/multi/alpha.txt", cx);
4733 select_path_with_mark(&panel, "root_a/multi/beta.txt", cx);
4734 drag_selection_to(&panel, "root_b/dst", false, cx);
4735 assert!(
4736 find_project_entry(&panel, "root_b/dst/alpha.txt", cx).is_some()
4737 && find_project_entry(&panel, "root_b/dst/beta.txt", cx).is_some(),
4738 "All marked entries should move to the destination worktree"
4739 );
4740 assert_eq!(
4741 find_project_entry(&panel, "root_a/multi/alpha.txt", cx),
4742 None,
4743 "Marked entries should be removed from the origin worktree"
4744 );
4745 assert_eq!(
4746 find_project_entry(&panel, "root_a/multi/beta.txt", cx),
4747 None,
4748 "Marked entries should be removed from the origin worktree"
4749 );
4750}
4751
4752#[gpui::test]
4753async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) {
4754 init_test(cx);
4755
4756 let fs = FakeFs::new(cx.executor());
4757 fs.insert_tree(
4758 "/root",
4759 json!({
4760 "src": {
4761 "folder1": {
4762 "mod.rs": "// folder1 mod"
4763 },
4764 "folder2": {
4765 "mod.rs": "// folder2 mod"
4766 },
4767 "folder3": {
4768 "mod.rs": "// folder3 mod",
4769 "helper.rs": "// helper"
4770 },
4771 "main.rs": ""
4772 }
4773 }),
4774 )
4775 .await;
4776
4777 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4778 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4779 let workspace = window
4780 .read_with(cx, |mw, _| mw.workspace().clone())
4781 .unwrap();
4782 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4783 let panel = workspace.update_in(cx, ProjectPanel::new);
4784 cx.run_until_parked();
4785
4786 toggle_expand_dir(&panel, "root/src", cx);
4787 toggle_expand_dir(&panel, "root/src/folder1", cx);
4788 toggle_expand_dir(&panel, "root/src/folder2", cx);
4789 toggle_expand_dir(&panel, "root/src/folder3", cx);
4790 cx.run_until_parked();
4791
4792 // Case 1: Dragging a folder and a file from a sibling folder together.
4793 panel.update(cx, |panel, _| panel.marked_entries.clear());
4794 select_path_with_mark(&panel, "root/src/folder1", cx);
4795 select_path_with_mark(&panel, "root/src/folder2/mod.rs", cx);
4796
4797 drag_selection_to(&panel, "root", false, cx);
4798
4799 assert!(
4800 find_project_entry(&panel, "root/folder1", cx).is_some(),
4801 "folder1 should be at root after drag"
4802 );
4803 assert!(
4804 find_project_entry(&panel, "root/folder1/mod.rs", cx).is_some(),
4805 "folder1/mod.rs should still be inside folder1 after drag"
4806 );
4807 assert_eq!(
4808 find_project_entry(&panel, "root/src/folder1", cx),
4809 None,
4810 "folder1 should no longer be in src"
4811 );
4812 assert!(
4813 find_project_entry(&panel, "root/mod.rs", cx).is_some(),
4814 "mod.rs from folder2 should be at root"
4815 );
4816
4817 // Case 2: Dragging a folder and its own child together.
4818 panel.update(cx, |panel, _| panel.marked_entries.clear());
4819 select_path_with_mark(&panel, "root/src/folder3", cx);
4820 select_path_with_mark(&panel, "root/src/folder3/mod.rs", cx);
4821
4822 drag_selection_to(&panel, "root", false, cx);
4823
4824 assert!(
4825 find_project_entry(&panel, "root/folder3", cx).is_some(),
4826 "folder3 should be at root after drag"
4827 );
4828 assert!(
4829 find_project_entry(&panel, "root/folder3/mod.rs", cx).is_some(),
4830 "folder3/mod.rs should still be inside folder3"
4831 );
4832 assert!(
4833 find_project_entry(&panel, "root/folder3/helper.rs", cx).is_some(),
4834 "folder3/helper.rs should still be inside folder3"
4835 );
4836}
4837
4838#[gpui::test]
4839async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4840 init_test_with_editor(cx);
4841 cx.update(|cx| {
4842 cx.update_global::<SettingsStore, _>(|store, cx| {
4843 store.update_user_settings(cx, |settings| {
4844 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
4845 settings
4846 .project_panel
4847 .get_or_insert_default()
4848 .auto_reveal_entries = Some(false);
4849 });
4850 })
4851 });
4852
4853 let fs = FakeFs::new(cx.background_executor.clone());
4854 fs.insert_tree(
4855 "/project_root",
4856 json!({
4857 ".git": {},
4858 ".gitignore": "**/gitignored_dir",
4859 "dir_1": {
4860 "file_1.py": "# File 1_1 contents",
4861 "file_2.py": "# File 1_2 contents",
4862 "file_3.py": "# File 1_3 contents",
4863 "gitignored_dir": {
4864 "file_a.py": "# File contents",
4865 "file_b.py": "# File contents",
4866 "file_c.py": "# File contents",
4867 },
4868 },
4869 "dir_2": {
4870 "file_1.py": "# File 2_1 contents",
4871 "file_2.py": "# File 2_2 contents",
4872 "file_3.py": "# File 2_3 contents",
4873 }
4874 }),
4875 )
4876 .await;
4877
4878 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4879 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4880 let workspace = window
4881 .read_with(cx, |mw, _| mw.workspace().clone())
4882 .unwrap();
4883 let cx = &mut VisualTestContext::from_window(window.into(), cx);
4884 let panel = workspace.update_in(cx, ProjectPanel::new);
4885 cx.run_until_parked();
4886
4887 assert_eq!(
4888 visible_entries_as_strings(&panel, 0..20, cx),
4889 &[
4890 "v project_root",
4891 " > .git",
4892 " > dir_1",
4893 " > dir_2",
4894 " .gitignore",
4895 ]
4896 );
4897
4898 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4899 .expect("dir 1 file is not ignored and should have an entry");
4900 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4901 .expect("dir 2 file is not ignored and should have an entry");
4902 let gitignored_dir_file =
4903 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4904 assert_eq!(
4905 gitignored_dir_file, None,
4906 "File in the gitignored dir should not have an entry before its dir is toggled"
4907 );
4908
4909 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4910 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4911 cx.executor().run_until_parked();
4912 assert_eq!(
4913 visible_entries_as_strings(&panel, 0..20, cx),
4914 &[
4915 "v project_root",
4916 " > .git",
4917 " v dir_1",
4918 " v gitignored_dir <== selected",
4919 " file_a.py",
4920 " file_b.py",
4921 " file_c.py",
4922 " file_1.py",
4923 " file_2.py",
4924 " file_3.py",
4925 " > dir_2",
4926 " .gitignore",
4927 ],
4928 "Should show gitignored dir file list in the project panel"
4929 );
4930 let gitignored_dir_file =
4931 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4932 .expect("after gitignored dir got opened, a file entry should be present");
4933
4934 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4935 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4936 assert_eq!(
4937 visible_entries_as_strings(&panel, 0..20, cx),
4938 &[
4939 "v project_root",
4940 " > .git",
4941 " > dir_1 <== selected",
4942 " > dir_2",
4943 " .gitignore",
4944 ],
4945 "Should hide all dir contents again and prepare for the auto reveal test"
4946 );
4947
4948 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4949 panel.update(cx, |panel, cx| {
4950 panel.project.update(cx, |_, cx| {
4951 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4952 })
4953 });
4954 cx.run_until_parked();
4955 assert_eq!(
4956 visible_entries_as_strings(&panel, 0..20, cx),
4957 &[
4958 "v project_root",
4959 " > .git",
4960 " > dir_1 <== selected",
4961 " > dir_2",
4962 " .gitignore",
4963 ],
4964 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4965 );
4966 }
4967
4968 cx.update(|_, cx| {
4969 cx.update_global::<SettingsStore, _>(|store, cx| {
4970 store.update_user_settings(cx, |settings| {
4971 settings
4972 .project_panel
4973 .get_or_insert_default()
4974 .auto_reveal_entries = Some(true)
4975 });
4976 })
4977 });
4978
4979 panel.update(cx, |panel, cx| {
4980 panel.project.update(cx, |_, cx| {
4981 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4982 })
4983 });
4984 cx.run_until_parked();
4985 assert_eq!(
4986 visible_entries_as_strings(&panel, 0..20, cx),
4987 &[
4988 "v project_root",
4989 " > .git",
4990 " v dir_1",
4991 " > gitignored_dir",
4992 " file_1.py <== selected <== marked",
4993 " file_2.py",
4994 " file_3.py",
4995 " > dir_2",
4996 " .gitignore",
4997 ],
4998 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4999 );
5000
5001 panel.update(cx, |panel, cx| {
5002 panel.project.update(cx, |_, cx| {
5003 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5004 })
5005 });
5006 cx.run_until_parked();
5007 assert_eq!(
5008 visible_entries_as_strings(&panel, 0..20, cx),
5009 &[
5010 "v project_root",
5011 " > .git",
5012 " v dir_1",
5013 " > gitignored_dir",
5014 " file_1.py",
5015 " file_2.py",
5016 " file_3.py",
5017 " v dir_2",
5018 " file_1.py <== selected <== marked",
5019 " file_2.py",
5020 " file_3.py",
5021 " .gitignore",
5022 ],
5023 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
5024 );
5025
5026 panel.update(cx, |panel, cx| {
5027 panel.project.update(cx, |_, cx| {
5028 cx.emit(project::Event::ActiveEntryChanged(Some(
5029 gitignored_dir_file,
5030 )))
5031 })
5032 });
5033 cx.run_until_parked();
5034 assert_eq!(
5035 visible_entries_as_strings(&panel, 0..20, cx),
5036 &[
5037 "v project_root",
5038 " > .git",
5039 " v dir_1",
5040 " > gitignored_dir",
5041 " file_1.py",
5042 " file_2.py",
5043 " file_3.py",
5044 " v dir_2",
5045 " file_1.py <== selected <== marked",
5046 " file_2.py",
5047 " file_3.py",
5048 " .gitignore",
5049 ],
5050 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5051 );
5052
5053 panel.update(cx, |panel, cx| {
5054 panel.project.update(cx, |_, cx| {
5055 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5056 })
5057 });
5058 cx.run_until_parked();
5059 assert_eq!(
5060 visible_entries_as_strings(&panel, 0..20, cx),
5061 &[
5062 "v project_root",
5063 " > .git",
5064 " v dir_1",
5065 " v gitignored_dir",
5066 " file_a.py <== selected <== marked",
5067 " file_b.py",
5068 " file_c.py",
5069 " file_1.py",
5070 " file_2.py",
5071 " file_3.py",
5072 " v dir_2",
5073 " file_1.py",
5074 " file_2.py",
5075 " file_3.py",
5076 " .gitignore",
5077 ],
5078 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5079 );
5080
5081 panel.update(cx, |panel, cx| {
5082 panel.project.update(cx, |_, cx| {
5083 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5084 })
5085 });
5086 cx.run_until_parked();
5087 assert_eq!(
5088 visible_entries_as_strings(&panel, 0..20, cx),
5089 &[
5090 "v project_root",
5091 " > .git",
5092 " v dir_1",
5093 " v gitignored_dir",
5094 " file_a.py",
5095 " file_b.py",
5096 " file_c.py",
5097 " file_1.py",
5098 " file_2.py",
5099 " file_3.py",
5100 " v dir_2",
5101 " file_1.py <== selected <== marked",
5102 " file_2.py",
5103 " file_3.py",
5104 " .gitignore",
5105 ],
5106 "After switching to dir_2_file, it should be selected and marked"
5107 );
5108
5109 panel.update(cx, |panel, cx| {
5110 panel.project.update(cx, |_, cx| {
5111 cx.emit(project::Event::ActiveEntryChanged(Some(
5112 gitignored_dir_file,
5113 )))
5114 })
5115 });
5116 cx.run_until_parked();
5117 assert_eq!(
5118 visible_entries_as_strings(&panel, 0..20, cx),
5119 &[
5120 "v project_root",
5121 " > .git",
5122 " v dir_1",
5123 " v gitignored_dir",
5124 " file_a.py <== selected <== marked",
5125 " file_b.py",
5126 " file_c.py",
5127 " file_1.py",
5128 " file_2.py",
5129 " file_3.py",
5130 " v dir_2",
5131 " file_1.py",
5132 " file_2.py",
5133 " file_3.py",
5134 " .gitignore",
5135 ],
5136 "When a gitignored entry is already visible, auto reveal should mark it as selected"
5137 );
5138}
5139
5140#[gpui::test]
5141async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
5142 init_test_with_editor(cx);
5143 cx.update(|cx| {
5144 cx.update_global::<SettingsStore, _>(|store, cx| {
5145 store.update_user_settings(cx, |settings| {
5146 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5147 settings.project.worktree.file_scan_inclusions =
5148 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
5149 settings
5150 .project_panel
5151 .get_or_insert_default()
5152 .auto_reveal_entries = Some(false)
5153 });
5154 })
5155 });
5156
5157 let fs = FakeFs::new(cx.background_executor.clone());
5158 fs.insert_tree(
5159 "/project_root",
5160 json!({
5161 ".git": {},
5162 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
5163 "dir_1": {
5164 "file_1.py": "# File 1_1 contents",
5165 "file_2.py": "# File 1_2 contents",
5166 "file_3.py": "# File 1_3 contents",
5167 "gitignored_dir": {
5168 "file_a.py": "# File contents",
5169 "file_b.py": "# File contents",
5170 "file_c.py": "# File contents",
5171 },
5172 },
5173 "dir_2": {
5174 "file_1.py": "# File 2_1 contents",
5175 "file_2.py": "# File 2_2 contents",
5176 "file_3.py": "# File 2_3 contents",
5177 },
5178 "always_included_but_ignored_dir": {
5179 "file_a.py": "# File contents",
5180 "file_b.py": "# File contents",
5181 "file_c.py": "# File contents",
5182 },
5183 }),
5184 )
5185 .await;
5186
5187 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5188 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5189 let workspace = window
5190 .read_with(cx, |mw, _| mw.workspace().clone())
5191 .unwrap();
5192 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5193 let panel = workspace.update_in(cx, ProjectPanel::new);
5194 cx.run_until_parked();
5195
5196 assert_eq!(
5197 visible_entries_as_strings(&panel, 0..20, cx),
5198 &[
5199 "v project_root",
5200 " > .git",
5201 " > always_included_but_ignored_dir",
5202 " > dir_1",
5203 " > dir_2",
5204 " .gitignore",
5205 ]
5206 );
5207
5208 let gitignored_dir_file =
5209 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5210 let always_included_but_ignored_dir_file = find_project_entry(
5211 &panel,
5212 "project_root/always_included_but_ignored_dir/file_a.py",
5213 cx,
5214 )
5215 .expect("file that is .gitignored but set to always be included should have an entry");
5216 assert_eq!(
5217 gitignored_dir_file, None,
5218 "File in the gitignored dir should not have an entry unless its directory is toggled"
5219 );
5220
5221 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5222 cx.run_until_parked();
5223 cx.update(|_, cx| {
5224 cx.update_global::<SettingsStore, _>(|store, cx| {
5225 store.update_user_settings(cx, |settings| {
5226 settings
5227 .project_panel
5228 .get_or_insert_default()
5229 .auto_reveal_entries = Some(true)
5230 });
5231 })
5232 });
5233
5234 panel.update(cx, |panel, cx| {
5235 panel.project.update(cx, |_, cx| {
5236 cx.emit(project::Event::ActiveEntryChanged(Some(
5237 always_included_but_ignored_dir_file,
5238 )))
5239 })
5240 });
5241 cx.run_until_parked();
5242
5243 assert_eq!(
5244 visible_entries_as_strings(&panel, 0..20, cx),
5245 &[
5246 "v project_root",
5247 " > .git",
5248 " v always_included_but_ignored_dir",
5249 " file_a.py <== selected <== marked",
5250 " file_b.py",
5251 " file_c.py",
5252 " v dir_1",
5253 " > gitignored_dir",
5254 " file_1.py",
5255 " file_2.py",
5256 " file_3.py",
5257 " > dir_2",
5258 " .gitignore",
5259 ],
5260 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
5261 );
5262}
5263
5264#[gpui::test]
5265async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5266 init_test_with_editor(cx);
5267 cx.update(|cx| {
5268 cx.update_global::<SettingsStore, _>(|store, cx| {
5269 store.update_user_settings(cx, |settings| {
5270 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
5271 settings
5272 .project_panel
5273 .get_or_insert_default()
5274 .auto_reveal_entries = Some(false)
5275 });
5276 })
5277 });
5278
5279 let fs = FakeFs::new(cx.background_executor.clone());
5280 fs.insert_tree(
5281 "/project_root",
5282 json!({
5283 ".git": {},
5284 ".gitignore": "**/gitignored_dir",
5285 "dir_1": {
5286 "file_1.py": "# File 1_1 contents",
5287 "file_2.py": "# File 1_2 contents",
5288 "file_3.py": "# File 1_3 contents",
5289 "gitignored_dir": {
5290 "file_a.py": "# File contents",
5291 "file_b.py": "# File contents",
5292 "file_c.py": "# File contents",
5293 },
5294 },
5295 "dir_2": {
5296 "file_1.py": "# File 2_1 contents",
5297 "file_2.py": "# File 2_2 contents",
5298 "file_3.py": "# File 2_3 contents",
5299 }
5300 }),
5301 )
5302 .await;
5303
5304 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5305 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5306 let workspace = window
5307 .read_with(cx, |mw, _| mw.workspace().clone())
5308 .unwrap();
5309 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5310 let panel = workspace.update_in(cx, ProjectPanel::new);
5311 cx.run_until_parked();
5312
5313 assert_eq!(
5314 visible_entries_as_strings(&panel, 0..20, cx),
5315 &[
5316 "v project_root",
5317 " > .git",
5318 " > dir_1",
5319 " > dir_2",
5320 " .gitignore",
5321 ]
5322 );
5323
5324 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5325 .expect("dir 1 file is not ignored and should have an entry");
5326 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5327 .expect("dir 2 file is not ignored and should have an entry");
5328 let gitignored_dir_file =
5329 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5330 assert_eq!(
5331 gitignored_dir_file, None,
5332 "File in the gitignored dir should not have an entry before its dir is toggled"
5333 );
5334
5335 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5336 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5337 cx.run_until_parked();
5338 assert_eq!(
5339 visible_entries_as_strings(&panel, 0..20, cx),
5340 &[
5341 "v project_root",
5342 " > .git",
5343 " v dir_1",
5344 " v gitignored_dir <== selected",
5345 " file_a.py",
5346 " file_b.py",
5347 " file_c.py",
5348 " file_1.py",
5349 " file_2.py",
5350 " file_3.py",
5351 " > dir_2",
5352 " .gitignore",
5353 ],
5354 "Should show gitignored dir file list in the project panel"
5355 );
5356 let gitignored_dir_file =
5357 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5358 .expect("after gitignored dir got opened, a file entry should be present");
5359
5360 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5361 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5362 assert_eq!(
5363 visible_entries_as_strings(&panel, 0..20, cx),
5364 &[
5365 "v project_root",
5366 " > .git",
5367 " > dir_1 <== selected",
5368 " > dir_2",
5369 " .gitignore",
5370 ],
5371 "Should hide all dir contents again and prepare for the explicit reveal test"
5372 );
5373
5374 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5375 panel.update(cx, |panel, cx| {
5376 panel.project.update(cx, |_, cx| {
5377 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5378 })
5379 });
5380 cx.run_until_parked();
5381 assert_eq!(
5382 visible_entries_as_strings(&panel, 0..20, cx),
5383 &[
5384 "v project_root",
5385 " > .git",
5386 " > dir_1 <== selected",
5387 " > dir_2",
5388 " .gitignore",
5389 ],
5390 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5391 );
5392 }
5393
5394 panel.update(cx, |panel, cx| {
5395 panel.project.update(cx, |_, cx| {
5396 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5397 })
5398 });
5399 cx.run_until_parked();
5400 assert_eq!(
5401 visible_entries_as_strings(&panel, 0..20, cx),
5402 &[
5403 "v project_root",
5404 " > .git",
5405 " v dir_1",
5406 " > gitignored_dir",
5407 " file_1.py <== selected <== marked",
5408 " file_2.py",
5409 " file_3.py",
5410 " > dir_2",
5411 " .gitignore",
5412 ],
5413 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5414 );
5415
5416 panel.update(cx, |panel, cx| {
5417 panel.project.update(cx, |_, cx| {
5418 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5419 })
5420 });
5421 cx.run_until_parked();
5422 assert_eq!(
5423 visible_entries_as_strings(&panel, 0..20, cx),
5424 &[
5425 "v project_root",
5426 " > .git",
5427 " v dir_1",
5428 " > gitignored_dir",
5429 " file_1.py",
5430 " file_2.py",
5431 " file_3.py",
5432 " v dir_2",
5433 " file_1.py <== selected <== marked",
5434 " file_2.py",
5435 " file_3.py",
5436 " .gitignore",
5437 ],
5438 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5439 );
5440
5441 panel.update(cx, |panel, cx| {
5442 panel.project.update(cx, |_, cx| {
5443 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5444 })
5445 });
5446 cx.run_until_parked();
5447 assert_eq!(
5448 visible_entries_as_strings(&panel, 0..20, cx),
5449 &[
5450 "v project_root",
5451 " > .git",
5452 " v dir_1",
5453 " v gitignored_dir",
5454 " file_a.py <== selected <== marked",
5455 " file_b.py",
5456 " file_c.py",
5457 " file_1.py",
5458 " file_2.py",
5459 " file_3.py",
5460 " v dir_2",
5461 " file_1.py",
5462 " file_2.py",
5463 " file_3.py",
5464 " .gitignore",
5465 ],
5466 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5467 );
5468}
5469
5470#[gpui::test]
5471async fn test_reveal_in_project_panel_notifications(cx: &mut gpui::TestAppContext) {
5472 init_test_with_editor(cx);
5473 let fs = FakeFs::new(cx.background_executor.clone());
5474 fs.insert_tree(
5475 "/workspace",
5476 json!({
5477 "README.md": ""
5478 }),
5479 )
5480 .await;
5481
5482 let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
5483 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5484 let workspace = window
5485 .read_with(cx, |mw, _| mw.workspace().clone())
5486 .unwrap();
5487 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5488 let panel = workspace.update_in(cx, ProjectPanel::new);
5489 cx.run_until_parked();
5490
5491 // Ensure that, attempting to run `pane: reveal in project panel` without
5492 // any active item does nothing, i.e., does not focus the project panel but
5493 // it also does not show a notification.
5494 cx.dispatch_action(workspace::RevealInProjectPanel::default());
5495 cx.run_until_parked();
5496
5497 panel.update_in(cx, |panel, window, cx| {
5498 assert!(
5499 !panel.focus_handle(cx).is_focused(window),
5500 "Project panel should not be focused after attempting to reveal an invisible worktree entry"
5501 );
5502
5503 panel.workspace.update(cx, |workspace, cx| {
5504 assert!(
5505 workspace.active_item(cx).is_none(),
5506 "Workspace should not have an active item"
5507 );
5508 assert_eq!(
5509 workspace.notification_ids(),
5510 vec![],
5511 "No notification should be shown when there's no active item"
5512 );
5513 }).unwrap();
5514 });
5515
5516 // Create a file in a different folder than the one in the project so we can
5517 // later open it and ensure that, attempting to reveal it in the project
5518 // panel shows a notification and does not focus the project panel.
5519 fs.insert_tree(
5520 "/external",
5521 json!({
5522 "file.txt": "External File",
5523 }),
5524 )
5525 .await;
5526
5527 let (worktree, _) = project
5528 .update(cx, |project, cx| {
5529 project.find_or_create_worktree("/external/file.txt", false, cx)
5530 })
5531 .await
5532 .unwrap();
5533
5534 workspace
5535 .update_in(cx, |workspace, window, cx| {
5536 let worktree_id = worktree.read(cx).id();
5537 let path = rel_path("").into();
5538 let project_path = ProjectPath { worktree_id, path };
5539
5540 workspace.open_path(project_path, None, true, window, cx)
5541 })
5542 .await
5543 .unwrap();
5544 cx.run_until_parked();
5545
5546 cx.dispatch_action(workspace::RevealInProjectPanel::default());
5547 cx.run_until_parked();
5548
5549 panel.update_in(cx, |panel, window, cx| {
5550 assert!(
5551 !panel.focus_handle(cx).is_focused(window),
5552 "Project panel should not be focused after attempting to reveal an invisible worktree entry"
5553 );
5554
5555 panel.workspace.update(cx, |workspace, cx| {
5556 assert!(
5557 workspace.active_item(cx).is_some(),
5558 "Workspace should have an active item"
5559 );
5560
5561 let notification_ids = workspace.notification_ids();
5562 assert_eq!(
5563 notification_ids.len(),
5564 1,
5565 "A notification should be shown when trying to reveal an invisible worktree entry"
5566 );
5567
5568 workspace.dismiss_notification(¬ification_ids[0], cx);
5569 assert_eq!(
5570 workspace.notification_ids().len(),
5571 0,
5572 "No notifications should be left after dismissing"
5573 );
5574 }).unwrap();
5575 });
5576
5577 // Create an empty buffer so we can ensure that, attempting to reveal it in
5578 // the project panel shows a notification and does not focus the project
5579 // panel.
5580 let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
5581 pane.update_in(cx, |pane, window, cx| {
5582 let item = cx.new(|cx| TestItem::new(cx).with_label("Unsaved buffer"));
5583 pane.add_item(Box::new(item), false, false, None, window, cx);
5584 });
5585
5586 cx.dispatch_action(workspace::RevealInProjectPanel::default());
5587 cx.run_until_parked();
5588
5589 panel.update_in(cx, |panel, window, cx| {
5590 assert!(
5591 !panel.focus_handle(cx).is_focused(window),
5592 "Project panel should not be focused after attempting to reveal an unsaved buffer"
5593 );
5594
5595 panel
5596 .workspace
5597 .update(cx, |workspace, cx| {
5598 assert!(
5599 workspace.active_item(cx).is_some(),
5600 "Workspace should have an active item"
5601 );
5602
5603 let notification_ids = workspace.notification_ids();
5604 assert_eq!(
5605 notification_ids.len(),
5606 1,
5607 "A notification should be shown when trying to reveal an unsaved buffer"
5608 );
5609 })
5610 .unwrap();
5611 });
5612}
5613
5614#[gpui::test]
5615async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5616 init_test(cx);
5617 cx.update(|cx| {
5618 cx.update_global::<SettingsStore, _>(|store, cx| {
5619 store.update_user_settings(cx, |settings| {
5620 settings.project.worktree.file_scan_exclusions =
5621 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5622 });
5623 });
5624 });
5625
5626 cx.update(|cx| {
5627 register_project_item::<TestProjectItemView>(cx);
5628 });
5629
5630 let fs = FakeFs::new(cx.executor());
5631 fs.insert_tree(
5632 "/root1",
5633 json!({
5634 ".dockerignore": "",
5635 ".git": {
5636 "HEAD": "",
5637 },
5638 }),
5639 )
5640 .await;
5641
5642 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5643 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5644 let workspace = window
5645 .read_with(cx, |mw, _| mw.workspace().clone())
5646 .unwrap();
5647 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5648 let panel = workspace.update_in(cx, |workspace, window, cx| {
5649 let panel = ProjectPanel::new(workspace, window, cx);
5650 workspace.add_panel(panel.clone(), window, cx);
5651 panel
5652 });
5653 cx.run_until_parked();
5654
5655 select_path(&panel, "root1", cx);
5656 assert_eq!(
5657 visible_entries_as_strings(&panel, 0..10, cx),
5658 &["v root1 <== selected", " .dockerignore",]
5659 );
5660 workspace.update_in(cx, |workspace, _, cx| {
5661 assert!(
5662 workspace.active_item(cx).is_none(),
5663 "Should have no active items in the beginning"
5664 );
5665 });
5666
5667 let excluded_file_path = ".git/COMMIT_EDITMSG";
5668 let excluded_dir_path = "excluded_dir";
5669
5670 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5671 cx.run_until_parked();
5672 panel.update_in(cx, |panel, window, cx| {
5673 assert!(panel.filename_editor.read(cx).is_focused(window));
5674 });
5675 panel
5676 .update_in(cx, |panel, window, cx| {
5677 panel.filename_editor.update(cx, |editor, cx| {
5678 editor.set_text(excluded_file_path, window, cx)
5679 });
5680 panel.confirm_edit(true, window, cx).unwrap()
5681 })
5682 .await
5683 .unwrap();
5684
5685 assert_eq!(
5686 visible_entries_as_strings(&panel, 0..13, cx),
5687 &["v root1", " .dockerignore"],
5688 "Excluded dir should not be shown after opening a file in it"
5689 );
5690 panel.update_in(cx, |panel, window, cx| {
5691 assert!(
5692 !panel.filename_editor.read(cx).is_focused(window),
5693 "Should have closed the file name editor"
5694 );
5695 });
5696 workspace.update_in(cx, |workspace, _, cx| {
5697 let active_entry_path = workspace
5698 .active_item(cx)
5699 .expect("should have opened and activated the excluded item")
5700 .act_as::<TestProjectItemView>(cx)
5701 .expect("should have opened the corresponding project item for the excluded item")
5702 .read(cx)
5703 .path
5704 .clone();
5705 assert_eq!(
5706 active_entry_path.path.as_ref(),
5707 rel_path(excluded_file_path),
5708 "Should open the excluded file"
5709 );
5710
5711 assert!(
5712 workspace.notification_ids().is_empty(),
5713 "Should have no notifications after opening an excluded file"
5714 );
5715 });
5716 assert!(
5717 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5718 "Should have created the excluded file"
5719 );
5720
5721 select_path(&panel, "root1", cx);
5722 panel.update_in(cx, |panel, window, cx| {
5723 panel.new_directory(&NewDirectory, window, cx)
5724 });
5725 cx.run_until_parked();
5726 panel.update_in(cx, |panel, window, cx| {
5727 assert!(panel.filename_editor.read(cx).is_focused(window));
5728 });
5729 panel
5730 .update_in(cx, |panel, window, cx| {
5731 panel.filename_editor.update(cx, |editor, cx| {
5732 editor.set_text(excluded_file_path, window, cx)
5733 });
5734 panel.confirm_edit(true, window, cx).unwrap()
5735 })
5736 .await
5737 .unwrap();
5738 cx.run_until_parked();
5739 assert_eq!(
5740 visible_entries_as_strings(&panel, 0..13, cx),
5741 &["v root1", " .dockerignore"],
5742 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5743 );
5744 panel.update_in(cx, |panel, window, cx| {
5745 assert!(
5746 !panel.filename_editor.read(cx).is_focused(window),
5747 "Should have closed the file name editor"
5748 );
5749 });
5750 workspace.update_in(cx, |workspace, _, cx| {
5751 let notifications = workspace.notification_ids();
5752 assert_eq!(
5753 notifications.len(),
5754 1,
5755 "Should receive one notification with the error message"
5756 );
5757 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5758 assert!(workspace.notification_ids().is_empty());
5759 });
5760
5761 select_path(&panel, "root1", cx);
5762 panel.update_in(cx, |panel, window, cx| {
5763 panel.new_directory(&NewDirectory, window, cx)
5764 });
5765 cx.run_until_parked();
5766
5767 panel.update_in(cx, |panel, window, cx| {
5768 assert!(panel.filename_editor.read(cx).is_focused(window));
5769 });
5770
5771 panel
5772 .update_in(cx, |panel, window, cx| {
5773 panel.filename_editor.update(cx, |editor, cx| {
5774 editor.set_text(excluded_dir_path, window, cx)
5775 });
5776 panel.confirm_edit(true, window, cx).unwrap()
5777 })
5778 .await
5779 .unwrap();
5780
5781 cx.run_until_parked();
5782
5783 assert_eq!(
5784 visible_entries_as_strings(&panel, 0..13, cx),
5785 &["v root1", " .dockerignore"],
5786 "Should not change the project panel after trying to create an excluded directory"
5787 );
5788 panel.update_in(cx, |panel, window, cx| {
5789 assert!(
5790 !panel.filename_editor.read(cx).is_focused(window),
5791 "Should have closed the file name editor"
5792 );
5793 });
5794 workspace.update_in(cx, |workspace, _, cx| {
5795 let notifications = workspace.notification_ids();
5796 assert_eq!(
5797 notifications.len(),
5798 1,
5799 "Should receive one notification explaining that no directory is actually shown"
5800 );
5801 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5802 assert!(workspace.notification_ids().is_empty());
5803 });
5804 assert!(
5805 fs.is_dir(Path::new("/root1/excluded_dir")).await,
5806 "Should have created the excluded directory"
5807 );
5808}
5809
5810#[gpui::test]
5811async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
5812 init_test_with_editor(cx);
5813
5814 let fs = FakeFs::new(cx.executor());
5815 fs.insert_tree(
5816 "/src",
5817 json!({
5818 "test": {
5819 "first.rs": "// First Rust file",
5820 "second.rs": "// Second Rust file",
5821 "third.rs": "// Third Rust file",
5822 }
5823 }),
5824 )
5825 .await;
5826
5827 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5828 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5829 let workspace = window
5830 .read_with(cx, |mw, _| mw.workspace().clone())
5831 .unwrap();
5832 let cx = &mut VisualTestContext::from_window(window.into(), cx);
5833 let panel = workspace.update_in(cx, |workspace, window, cx| {
5834 let panel = ProjectPanel::new(workspace, window, cx);
5835 workspace.add_panel(panel.clone(), window, cx);
5836 panel
5837 });
5838 cx.run_until_parked();
5839
5840 select_path(&panel, "src", cx);
5841 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
5842 cx.executor().run_until_parked();
5843 assert_eq!(
5844 visible_entries_as_strings(&panel, 0..10, cx),
5845 &[
5846 //
5847 "v src <== selected",
5848 " > test"
5849 ]
5850 );
5851 panel.update_in(cx, |panel, window, cx| {
5852 panel.new_directory(&NewDirectory, window, cx)
5853 });
5854 cx.executor().run_until_parked();
5855 panel.update_in(cx, |panel, window, cx| {
5856 assert!(panel.filename_editor.read(cx).is_focused(window));
5857 });
5858 assert_eq!(
5859 visible_entries_as_strings(&panel, 0..10, cx),
5860 &[
5861 //
5862 "v src",
5863 " > [EDITOR: ''] <== selected",
5864 " > test"
5865 ]
5866 );
5867
5868 panel.update_in(cx, |panel, window, cx| {
5869 panel.cancel(&menu::Cancel, window, cx);
5870 });
5871 cx.executor().run_until_parked();
5872 assert_eq!(
5873 visible_entries_as_strings(&panel, 0..10, cx),
5874 &[
5875 //
5876 "v src <== selected",
5877 " > test"
5878 ]
5879 );
5880
5881 panel.update_in(cx, |panel, window, cx| {
5882 panel.new_directory(&NewDirectory, window, cx)
5883 });
5884 cx.executor().run_until_parked();
5885 panel.update_in(cx, |panel, window, cx| {
5886 assert!(panel.filename_editor.read(cx).is_focused(window));
5887 });
5888 assert_eq!(
5889 visible_entries_as_strings(&panel, 0..10, cx),
5890 &[
5891 //
5892 "v src",
5893 " > [EDITOR: ''] <== selected",
5894 " > test"
5895 ]
5896 );
5897 workspace.update_in(cx, |_, window, _| window.blur());
5898 cx.executor().run_until_parked();
5899 assert_eq!(
5900 visible_entries_as_strings(&panel, 0..10, cx),
5901 &[
5902 //
5903 "v src <== selected",
5904 " > test"
5905 ]
5906 );
5907}
5908
5909#[gpui::test]
5910async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
5911 init_test_with_editor(cx);
5912
5913 let fs = FakeFs::new(cx.executor());
5914 fs.insert_tree(
5915 "/root",
5916 json!({
5917 "dir1": {
5918 "subdir1": {},
5919 "file1.txt": "",
5920 "file2.txt": "",
5921 },
5922 "dir2": {
5923 "subdir2": {},
5924 "file3.txt": "",
5925 "file4.txt": "",
5926 },
5927 "file5.txt": "",
5928 "file6.txt": "",
5929 }),
5930 )
5931 .await;
5932
5933 let project = Project::test(fs.clone(), ["/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 toggle_expand_dir(&panel, "root/dir1", cx);
5943 toggle_expand_dir(&panel, "root/dir2", cx);
5944
5945 // Test Case 1: Delete middle file in directory
5946 select_path(&panel, "root/dir1/file1.txt", cx);
5947 assert_eq!(
5948 visible_entries_as_strings(&panel, 0..15, cx),
5949 &[
5950 "v root",
5951 " v dir1",
5952 " > subdir1",
5953 " file1.txt <== selected",
5954 " file2.txt",
5955 " v dir2",
5956 " > subdir2",
5957 " file3.txt",
5958 " file4.txt",
5959 " file5.txt",
5960 " file6.txt",
5961 ],
5962 "Initial state before deleting middle file"
5963 );
5964
5965 submit_deletion(&panel, cx);
5966 assert_eq!(
5967 visible_entries_as_strings(&panel, 0..15, cx),
5968 &[
5969 "v root",
5970 " v dir1",
5971 " > subdir1",
5972 " file2.txt <== selected",
5973 " v dir2",
5974 " > subdir2",
5975 " file3.txt",
5976 " file4.txt",
5977 " file5.txt",
5978 " file6.txt",
5979 ],
5980 "Should select next file after deleting middle file"
5981 );
5982
5983 // Test Case 2: Delete last file in directory
5984 submit_deletion(&panel, cx);
5985 assert_eq!(
5986 visible_entries_as_strings(&panel, 0..15, cx),
5987 &[
5988 "v root",
5989 " v dir1",
5990 " > subdir1 <== selected",
5991 " v dir2",
5992 " > subdir2",
5993 " file3.txt",
5994 " file4.txt",
5995 " file5.txt",
5996 " file6.txt",
5997 ],
5998 "Should select next directory when last file is deleted"
5999 );
6000
6001 // Test Case 3: Delete root level file
6002 select_path(&panel, "root/file6.txt", cx);
6003 assert_eq!(
6004 visible_entries_as_strings(&panel, 0..15, cx),
6005 &[
6006 "v root",
6007 " v dir1",
6008 " > subdir1",
6009 " v dir2",
6010 " > subdir2",
6011 " file3.txt",
6012 " file4.txt",
6013 " file5.txt",
6014 " file6.txt <== selected",
6015 ],
6016 "Initial state before deleting root level file"
6017 );
6018
6019 submit_deletion(&panel, cx);
6020 assert_eq!(
6021 visible_entries_as_strings(&panel, 0..15, cx),
6022 &[
6023 "v root",
6024 " v dir1",
6025 " > subdir1",
6026 " v dir2",
6027 " > subdir2",
6028 " file3.txt",
6029 " file4.txt",
6030 " file5.txt <== selected",
6031 ],
6032 "Should select prev entry at root level"
6033 );
6034}
6035
6036#[gpui::test]
6037async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
6038 init_test_with_editor(cx);
6039
6040 let fs = FakeFs::new(cx.executor());
6041 fs.insert_tree(
6042 path!("/root"),
6043 json!({
6044 "aa": "// Testing 1",
6045 "bb": "// Testing 2",
6046 "cc": "// Testing 3",
6047 "dd": "// Testing 4",
6048 "ee": "// Testing 5",
6049 "ff": "// Testing 6",
6050 "gg": "// Testing 7",
6051 "hh": "// Testing 8",
6052 "ii": "// Testing 8",
6053 ".gitignore": "bb\ndd\nee\nff\nii\n'",
6054 }),
6055 )
6056 .await;
6057
6058 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6059 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6060 let workspace = window
6061 .read_with(cx, |mw, _| mw.workspace().clone())
6062 .unwrap();
6063 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6064
6065 // Test 1: Auto selection with one gitignored file next to the deleted file
6066 cx.update(|_, cx| {
6067 let settings = *ProjectPanelSettings::get_global(cx);
6068 ProjectPanelSettings::override_global(
6069 ProjectPanelSettings {
6070 hide_gitignore: true,
6071 ..settings
6072 },
6073 cx,
6074 );
6075 });
6076
6077 let panel = workspace.update_in(cx, ProjectPanel::new);
6078 cx.run_until_parked();
6079
6080 select_path(&panel, "root/aa", cx);
6081 assert_eq!(
6082 visible_entries_as_strings(&panel, 0..10, cx),
6083 &[
6084 "v root",
6085 " .gitignore",
6086 " aa <== selected",
6087 " cc",
6088 " gg",
6089 " hh"
6090 ],
6091 "Initial state should hide files on .gitignore"
6092 );
6093
6094 submit_deletion(&panel, cx);
6095
6096 assert_eq!(
6097 visible_entries_as_strings(&panel, 0..10, cx),
6098 &[
6099 "v root",
6100 " .gitignore",
6101 " cc <== selected",
6102 " gg",
6103 " hh"
6104 ],
6105 "Should select next entry not on .gitignore"
6106 );
6107
6108 // Test 2: Auto selection with many gitignored files next to the deleted file
6109 submit_deletion(&panel, cx);
6110 assert_eq!(
6111 visible_entries_as_strings(&panel, 0..10, cx),
6112 &[
6113 "v root",
6114 " .gitignore",
6115 " gg <== selected",
6116 " hh"
6117 ],
6118 "Should select next entry not on .gitignore"
6119 );
6120
6121 // Test 3: Auto selection of entry before deleted file
6122 select_path(&panel, "root/hh", cx);
6123 assert_eq!(
6124 visible_entries_as_strings(&panel, 0..10, cx),
6125 &[
6126 "v root",
6127 " .gitignore",
6128 " gg",
6129 " hh <== selected"
6130 ],
6131 "Should select next entry not on .gitignore"
6132 );
6133 submit_deletion(&panel, cx);
6134 assert_eq!(
6135 visible_entries_as_strings(&panel, 0..10, cx),
6136 &["v root", " .gitignore", " gg <== selected"],
6137 "Should select next entry not on .gitignore"
6138 );
6139}
6140
6141#[gpui::test]
6142async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
6143 init_test_with_editor(cx);
6144
6145 let fs = FakeFs::new(cx.executor());
6146 fs.insert_tree(
6147 path!("/root"),
6148 json!({
6149 "dir1": {
6150 "file1": "// Testing",
6151 "file2": "// Testing",
6152 "file3": "// Testing"
6153 },
6154 "aa": "// Testing",
6155 ".gitignore": "file1\nfile3\n",
6156 }),
6157 )
6158 .await;
6159
6160 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6161 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6162 let workspace = window
6163 .read_with(cx, |mw, _| mw.workspace().clone())
6164 .unwrap();
6165 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6166
6167 cx.update(|_, cx| {
6168 let settings = *ProjectPanelSettings::get_global(cx);
6169 ProjectPanelSettings::override_global(
6170 ProjectPanelSettings {
6171 hide_gitignore: true,
6172 ..settings
6173 },
6174 cx,
6175 );
6176 });
6177
6178 let panel = workspace.update_in(cx, ProjectPanel::new);
6179 cx.run_until_parked();
6180
6181 // Test 1: Visible items should exclude files on gitignore
6182 toggle_expand_dir(&panel, "root/dir1", cx);
6183 select_path(&panel, "root/dir1/file2", cx);
6184 assert_eq!(
6185 visible_entries_as_strings(&panel, 0..10, cx),
6186 &[
6187 "v root",
6188 " v dir1",
6189 " file2 <== selected",
6190 " .gitignore",
6191 " aa"
6192 ],
6193 "Initial state should hide files on .gitignore"
6194 );
6195 submit_deletion(&panel, cx);
6196
6197 // Test 2: Auto selection should go to the parent
6198 assert_eq!(
6199 visible_entries_as_strings(&panel, 0..10, cx),
6200 &[
6201 "v root",
6202 " v dir1 <== selected",
6203 " .gitignore",
6204 " aa"
6205 ],
6206 "Initial state should hide files on .gitignore"
6207 );
6208}
6209
6210#[gpui::test]
6211async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
6212 init_test_with_editor(cx);
6213
6214 let fs = FakeFs::new(cx.executor());
6215 fs.insert_tree(
6216 "/root",
6217 json!({
6218 "dir1": {
6219 "subdir1": {
6220 "a.txt": "",
6221 "b.txt": ""
6222 },
6223 "file1.txt": "",
6224 },
6225 "dir2": {
6226 "subdir2": {
6227 "c.txt": "",
6228 "d.txt": ""
6229 },
6230 "file2.txt": "",
6231 },
6232 "file3.txt": "",
6233 }),
6234 )
6235 .await;
6236
6237 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6238 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6239 let workspace = window
6240 .read_with(cx, |mw, _| mw.workspace().clone())
6241 .unwrap();
6242 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6243 let panel = workspace.update_in(cx, ProjectPanel::new);
6244 cx.run_until_parked();
6245
6246 toggle_expand_dir(&panel, "root/dir1", cx);
6247 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6248 toggle_expand_dir(&panel, "root/dir2", cx);
6249 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6250
6251 // Test Case 1: Select and delete nested directory with parent
6252 cx.simulate_modifiers_change(gpui::Modifiers {
6253 control: true,
6254 ..Default::default()
6255 });
6256 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6257 select_path_with_mark(&panel, "root/dir1", cx);
6258
6259 assert_eq!(
6260 visible_entries_as_strings(&panel, 0..15, cx),
6261 &[
6262 "v root",
6263 " v dir1 <== selected <== marked",
6264 " v subdir1 <== marked",
6265 " a.txt",
6266 " b.txt",
6267 " file1.txt",
6268 " v dir2",
6269 " v subdir2",
6270 " c.txt",
6271 " d.txt",
6272 " file2.txt",
6273 " file3.txt",
6274 ],
6275 "Initial state before deleting nested directory with parent"
6276 );
6277
6278 submit_deletion(&panel, cx);
6279 assert_eq!(
6280 visible_entries_as_strings(&panel, 0..15, cx),
6281 &[
6282 "v root",
6283 " v dir2 <== selected",
6284 " v subdir2",
6285 " c.txt",
6286 " d.txt",
6287 " file2.txt",
6288 " file3.txt",
6289 ],
6290 "Should select next directory after deleting directory with parent"
6291 );
6292
6293 // Test Case 2: Select mixed files and directories across levels
6294 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
6295 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
6296 select_path_with_mark(&panel, "root/file3.txt", cx);
6297
6298 assert_eq!(
6299 visible_entries_as_strings(&panel, 0..15, cx),
6300 &[
6301 "v root",
6302 " v dir2",
6303 " v subdir2",
6304 " c.txt <== marked",
6305 " d.txt",
6306 " file2.txt <== marked",
6307 " file3.txt <== selected <== marked",
6308 ],
6309 "Initial state before deleting"
6310 );
6311
6312 submit_deletion(&panel, cx);
6313 assert_eq!(
6314 visible_entries_as_strings(&panel, 0..15, cx),
6315 &[
6316 "v root",
6317 " v dir2 <== selected",
6318 " v subdir2",
6319 " d.txt",
6320 ],
6321 "Should select sibling directory"
6322 );
6323}
6324
6325#[gpui::test]
6326async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
6327 init_test_with_editor(cx);
6328
6329 let fs = FakeFs::new(cx.executor());
6330 fs.insert_tree(
6331 "/root",
6332 json!({
6333 "dir1": {
6334 "subdir1": {
6335 "a.txt": "",
6336 "b.txt": ""
6337 },
6338 "file1.txt": "",
6339 },
6340 "dir2": {
6341 "subdir2": {
6342 "c.txt": "",
6343 "d.txt": ""
6344 },
6345 "file2.txt": "",
6346 },
6347 "file3.txt": "",
6348 "file4.txt": "",
6349 }),
6350 )
6351 .await;
6352
6353 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6354 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6355 let workspace = window
6356 .read_with(cx, |mw, _| mw.workspace().clone())
6357 .unwrap();
6358 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6359 let panel = workspace.update_in(cx, ProjectPanel::new);
6360 cx.run_until_parked();
6361
6362 toggle_expand_dir(&panel, "root/dir1", cx);
6363 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6364 toggle_expand_dir(&panel, "root/dir2", cx);
6365 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6366
6367 // Test Case 1: Select all root files and directories
6368 cx.simulate_modifiers_change(gpui::Modifiers {
6369 control: true,
6370 ..Default::default()
6371 });
6372 select_path_with_mark(&panel, "root/dir1", cx);
6373 select_path_with_mark(&panel, "root/dir2", cx);
6374 select_path_with_mark(&panel, "root/file3.txt", cx);
6375 select_path_with_mark(&panel, "root/file4.txt", cx);
6376 assert_eq!(
6377 visible_entries_as_strings(&panel, 0..20, cx),
6378 &[
6379 "v root",
6380 " v dir1 <== marked",
6381 " v subdir1",
6382 " a.txt",
6383 " b.txt",
6384 " file1.txt",
6385 " v dir2 <== marked",
6386 " v subdir2",
6387 " c.txt",
6388 " d.txt",
6389 " file2.txt",
6390 " file3.txt <== marked",
6391 " file4.txt <== selected <== marked",
6392 ],
6393 "State before deleting all contents"
6394 );
6395
6396 submit_deletion(&panel, cx);
6397 assert_eq!(
6398 visible_entries_as_strings(&panel, 0..20, cx),
6399 &["v root <== selected"],
6400 "Only empty root directory should remain after deleting all contents"
6401 );
6402}
6403
6404#[gpui::test]
6405async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
6406 init_test_with_editor(cx);
6407
6408 let fs = FakeFs::new(cx.executor());
6409 fs.insert_tree(
6410 "/root",
6411 json!({
6412 "dir1": {
6413 "subdir1": {
6414 "file_a.txt": "content a",
6415 "file_b.txt": "content b",
6416 },
6417 "subdir2": {
6418 "file_c.txt": "content c",
6419 },
6420 "file1.txt": "content 1",
6421 },
6422 "dir2": {
6423 "file2.txt": "content 2",
6424 },
6425 }),
6426 )
6427 .await;
6428
6429 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6430 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6431 let workspace = window
6432 .read_with(cx, |mw, _| mw.workspace().clone())
6433 .unwrap();
6434 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6435 let panel = workspace.update_in(cx, ProjectPanel::new);
6436 cx.run_until_parked();
6437
6438 toggle_expand_dir(&panel, "root/dir1", cx);
6439 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6440 toggle_expand_dir(&panel, "root/dir2", cx);
6441 cx.simulate_modifiers_change(gpui::Modifiers {
6442 control: true,
6443 ..Default::default()
6444 });
6445
6446 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6447 select_path_with_mark(&panel, "root/dir1", cx);
6448 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6449 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6450
6451 assert_eq!(
6452 visible_entries_as_strings(&panel, 0..20, cx),
6453 &[
6454 "v root",
6455 " v dir1 <== marked",
6456 " v subdir1 <== marked",
6457 " file_a.txt <== selected <== marked",
6458 " file_b.txt",
6459 " > subdir2",
6460 " file1.txt",
6461 " v dir2",
6462 " file2.txt",
6463 ],
6464 "State with parent dir, subdir, and file selected"
6465 );
6466 submit_deletion(&panel, cx);
6467 assert_eq!(
6468 visible_entries_as_strings(&panel, 0..20, cx),
6469 &["v root", " v dir2 <== selected", " file2.txt",],
6470 "Only dir2 should remain after deletion"
6471 );
6472}
6473
6474#[gpui::test]
6475async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
6476 init_test_with_editor(cx);
6477
6478 let fs = FakeFs::new(cx.executor());
6479 // First worktree
6480 fs.insert_tree(
6481 "/root1",
6482 json!({
6483 "dir1": {
6484 "file1.txt": "content 1",
6485 "file2.txt": "content 2",
6486 },
6487 "dir2": {
6488 "file3.txt": "content 3",
6489 },
6490 }),
6491 )
6492 .await;
6493
6494 // Second worktree
6495 fs.insert_tree(
6496 "/root2",
6497 json!({
6498 "dir3": {
6499 "file4.txt": "content 4",
6500 "file5.txt": "content 5",
6501 },
6502 "file6.txt": "content 6",
6503 }),
6504 )
6505 .await;
6506
6507 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6508 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6509 let workspace = window
6510 .read_with(cx, |mw, _| mw.workspace().clone())
6511 .unwrap();
6512 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6513 let panel = workspace.update_in(cx, ProjectPanel::new);
6514 cx.run_until_parked();
6515
6516 // Expand all directories for testing
6517 toggle_expand_dir(&panel, "root1/dir1", cx);
6518 toggle_expand_dir(&panel, "root1/dir2", cx);
6519 toggle_expand_dir(&panel, "root2/dir3", cx);
6520
6521 // Test Case 1: Delete files across different worktrees
6522 cx.simulate_modifiers_change(gpui::Modifiers {
6523 control: true,
6524 ..Default::default()
6525 });
6526 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
6527 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
6528
6529 assert_eq!(
6530 visible_entries_as_strings(&panel, 0..20, cx),
6531 &[
6532 "v root1",
6533 " v dir1",
6534 " file1.txt <== marked",
6535 " file2.txt",
6536 " v dir2",
6537 " file3.txt",
6538 "v root2",
6539 " v dir3",
6540 " file4.txt <== selected <== marked",
6541 " file5.txt",
6542 " file6.txt",
6543 ],
6544 "Initial state with files selected from different worktrees"
6545 );
6546
6547 submit_deletion(&panel, cx);
6548 assert_eq!(
6549 visible_entries_as_strings(&panel, 0..20, cx),
6550 &[
6551 "v root1",
6552 " v dir1",
6553 " file2.txt",
6554 " v dir2",
6555 " file3.txt",
6556 "v root2",
6557 " v dir3",
6558 " file5.txt <== selected",
6559 " file6.txt",
6560 ],
6561 "Should select next file in the last worktree after deletion"
6562 );
6563
6564 // Test Case 2: Delete directories from different worktrees
6565 select_path_with_mark(&panel, "root1/dir1", cx);
6566 select_path_with_mark(&panel, "root2/dir3", cx);
6567
6568 assert_eq!(
6569 visible_entries_as_strings(&panel, 0..20, cx),
6570 &[
6571 "v root1",
6572 " v dir1 <== marked",
6573 " file2.txt",
6574 " v dir2",
6575 " file3.txt",
6576 "v root2",
6577 " v dir3 <== selected <== marked",
6578 " file5.txt",
6579 " file6.txt",
6580 ],
6581 "State with directories marked from different worktrees"
6582 );
6583
6584 submit_deletion(&panel, cx);
6585 assert_eq!(
6586 visible_entries_as_strings(&panel, 0..20, cx),
6587 &[
6588 "v root1",
6589 " v dir2",
6590 " file3.txt",
6591 "v root2",
6592 " file6.txt <== selected",
6593 ],
6594 "Should select remaining file in last worktree after directory deletion"
6595 );
6596
6597 // Test Case 4: Delete all remaining files except roots
6598 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
6599 select_path_with_mark(&panel, "root2/file6.txt", cx);
6600
6601 assert_eq!(
6602 visible_entries_as_strings(&panel, 0..20, cx),
6603 &[
6604 "v root1",
6605 " v dir2",
6606 " file3.txt <== marked",
6607 "v root2",
6608 " file6.txt <== selected <== marked",
6609 ],
6610 "State with all remaining files marked"
6611 );
6612
6613 submit_deletion(&panel, cx);
6614 assert_eq!(
6615 visible_entries_as_strings(&panel, 0..20, cx),
6616 &["v root1", " v dir2", "v root2 <== selected"],
6617 "Second parent root should be selected after deleting"
6618 );
6619}
6620
6621#[gpui::test]
6622async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
6623 init_test_with_editor(cx);
6624
6625 let fs = FakeFs::new(cx.executor());
6626 fs.insert_tree(
6627 "/root",
6628 json!({
6629 "dir1": {
6630 "file1.txt": "",
6631 "file2.txt": "",
6632 "file3.txt": "",
6633 },
6634 "dir2": {
6635 "file4.txt": "",
6636 "file5.txt": "",
6637 },
6638 }),
6639 )
6640 .await;
6641
6642 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6643 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6644 let workspace = window
6645 .read_with(cx, |mw, _| mw.workspace().clone())
6646 .unwrap();
6647 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6648 let panel = workspace.update_in(cx, ProjectPanel::new);
6649 cx.run_until_parked();
6650
6651 toggle_expand_dir(&panel, "root/dir1", cx);
6652 toggle_expand_dir(&panel, "root/dir2", cx);
6653
6654 cx.simulate_modifiers_change(gpui::Modifiers {
6655 control: true,
6656 ..Default::default()
6657 });
6658
6659 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
6660 select_path(&panel, "root/dir1/file1.txt", cx);
6661
6662 assert_eq!(
6663 visible_entries_as_strings(&panel, 0..15, cx),
6664 &[
6665 "v root",
6666 " v dir1",
6667 " file1.txt <== selected",
6668 " file2.txt <== marked",
6669 " file3.txt",
6670 " v dir2",
6671 " file4.txt",
6672 " file5.txt",
6673 ],
6674 "Initial state with one marked entry and different selection"
6675 );
6676
6677 // Delete should operate on the selected entry (file1.txt)
6678 submit_deletion(&panel, cx);
6679 assert_eq!(
6680 visible_entries_as_strings(&panel, 0..15, cx),
6681 &[
6682 "v root",
6683 " v dir1",
6684 " file2.txt <== selected <== marked",
6685 " file3.txt",
6686 " v dir2",
6687 " file4.txt",
6688 " file5.txt",
6689 ],
6690 "Should delete selected file, not marked file"
6691 );
6692
6693 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
6694 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
6695 select_path(&panel, "root/dir2/file5.txt", cx);
6696
6697 assert_eq!(
6698 visible_entries_as_strings(&panel, 0..15, cx),
6699 &[
6700 "v root",
6701 " v dir1",
6702 " file2.txt <== marked",
6703 " file3.txt <== marked",
6704 " v dir2",
6705 " file4.txt <== marked",
6706 " file5.txt <== selected",
6707 ],
6708 "Initial state with multiple marked entries and different selection"
6709 );
6710
6711 // Delete should operate on all marked entries, ignoring the selection
6712 submit_deletion(&panel, cx);
6713 assert_eq!(
6714 visible_entries_as_strings(&panel, 0..15, cx),
6715 &[
6716 "v root",
6717 " v dir1",
6718 " v dir2",
6719 " file5.txt <== selected",
6720 ],
6721 "Should delete all marked files, leaving only the selected file"
6722 );
6723}
6724
6725#[gpui::test]
6726async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
6727 init_test_with_editor(cx);
6728
6729 let fs = FakeFs::new(cx.executor());
6730 fs.insert_tree(
6731 "/root_b",
6732 json!({
6733 "dir1": {
6734 "file1.txt": "content 1",
6735 "file2.txt": "content 2",
6736 },
6737 }),
6738 )
6739 .await;
6740
6741 fs.insert_tree(
6742 "/root_c",
6743 json!({
6744 "dir2": {},
6745 }),
6746 )
6747 .await;
6748
6749 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
6750 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6751 let workspace = window
6752 .read_with(cx, |mw, _| mw.workspace().clone())
6753 .unwrap();
6754 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6755 let panel = workspace.update_in(cx, ProjectPanel::new);
6756 cx.run_until_parked();
6757
6758 toggle_expand_dir(&panel, "root_b/dir1", cx);
6759 toggle_expand_dir(&panel, "root_c/dir2", cx);
6760
6761 cx.simulate_modifiers_change(gpui::Modifiers {
6762 control: true,
6763 ..Default::default()
6764 });
6765 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
6766 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
6767
6768 assert_eq!(
6769 visible_entries_as_strings(&panel, 0..20, cx),
6770 &[
6771 "v root_b",
6772 " v dir1",
6773 " file1.txt <== marked",
6774 " file2.txt <== selected <== marked",
6775 "v root_c",
6776 " v dir2",
6777 ],
6778 "Initial state with files marked in root_b"
6779 );
6780
6781 submit_deletion(&panel, cx);
6782 assert_eq!(
6783 visible_entries_as_strings(&panel, 0..20, cx),
6784 &[
6785 "v root_b",
6786 " v dir1 <== selected",
6787 "v root_c",
6788 " v dir2",
6789 ],
6790 "After deletion in root_b as it's last deletion, selection should be in root_b"
6791 );
6792
6793 select_path_with_mark(&panel, "root_c/dir2", cx);
6794
6795 submit_deletion(&panel, cx);
6796 assert_eq!(
6797 visible_entries_as_strings(&panel, 0..20, cx),
6798 &["v root_b", " v dir1", "v root_c <== selected",],
6799 "After deleting from root_c, it should remain in root_c"
6800 );
6801}
6802
6803pub(crate) fn toggle_expand_dir(
6804 panel: &Entity<ProjectPanel>,
6805 path: &str,
6806 cx: &mut VisualTestContext,
6807) {
6808 let path = rel_path(path);
6809 panel.update_in(cx, |panel, window, cx| {
6810 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6811 let worktree = worktree.read(cx);
6812 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6813 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6814 panel.toggle_expanded(entry_id, window, cx);
6815 return;
6816 }
6817 }
6818 panic!("no worktree for path {:?}", path);
6819 });
6820 cx.run_until_parked();
6821}
6822
6823#[gpui::test]
6824async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
6825 init_test_with_editor(cx);
6826
6827 let fs = FakeFs::new(cx.executor());
6828 fs.insert_tree(
6829 path!("/root"),
6830 json!({
6831 ".gitignore": "**/ignored_dir\n**/ignored_nested",
6832 "dir1": {
6833 "empty1": {
6834 "empty2": {
6835 "empty3": {
6836 "file.txt": ""
6837 }
6838 }
6839 },
6840 "subdir1": {
6841 "file1.txt": "",
6842 "file2.txt": "",
6843 "ignored_nested": {
6844 "ignored_file.txt": ""
6845 }
6846 },
6847 "ignored_dir": {
6848 "subdir": {
6849 "deep_file.txt": ""
6850 }
6851 }
6852 }
6853 }),
6854 )
6855 .await;
6856
6857 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
6858 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6859 let workspace = window
6860 .read_with(cx, |mw, _| mw.workspace().clone())
6861 .unwrap();
6862 let cx = &mut VisualTestContext::from_window(window.into(), cx);
6863
6864 // Test 1: When auto-fold is enabled
6865 cx.update(|_, cx| {
6866 let settings = *ProjectPanelSettings::get_global(cx);
6867 ProjectPanelSettings::override_global(
6868 ProjectPanelSettings {
6869 auto_fold_dirs: true,
6870 ..settings
6871 },
6872 cx,
6873 );
6874 });
6875
6876 let panel = workspace.update_in(cx, ProjectPanel::new);
6877 cx.run_until_parked();
6878
6879 assert_eq!(
6880 visible_entries_as_strings(&panel, 0..20, cx),
6881 &["v root", " > dir1", " .gitignore",],
6882 "Initial state should show collapsed root structure"
6883 );
6884
6885 toggle_expand_dir(&panel, "root/dir1", cx);
6886 assert_eq!(
6887 visible_entries_as_strings(&panel, 0..20, cx),
6888 &[
6889 "v root",
6890 " v dir1 <== selected",
6891 " > empty1/empty2/empty3",
6892 " > ignored_dir",
6893 " > subdir1",
6894 " .gitignore",
6895 ],
6896 "Should show first level with auto-folded dirs and ignored dir visible"
6897 );
6898
6899 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6900 panel.update_in(cx, |panel, window, cx| {
6901 let project = panel.project.read(cx);
6902 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6903 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6904 panel.update_visible_entries(None, false, false, window, cx);
6905 });
6906 cx.run_until_parked();
6907
6908 assert_eq!(
6909 visible_entries_as_strings(&panel, 0..20, cx),
6910 &[
6911 "v root",
6912 " v dir1 <== selected",
6913 " v empty1",
6914 " v empty2",
6915 " v empty3",
6916 " file.txt",
6917 " > ignored_dir",
6918 " v subdir1",
6919 " > ignored_nested",
6920 " file1.txt",
6921 " file2.txt",
6922 " .gitignore",
6923 ],
6924 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
6925 );
6926
6927 // Test 2: When auto-fold is disabled
6928 cx.update(|_, cx| {
6929 let settings = *ProjectPanelSettings::get_global(cx);
6930 ProjectPanelSettings::override_global(
6931 ProjectPanelSettings {
6932 auto_fold_dirs: false,
6933 ..settings
6934 },
6935 cx,
6936 );
6937 });
6938
6939 panel.update_in(cx, |panel, window, cx| {
6940 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
6941 });
6942
6943 toggle_expand_dir(&panel, "root/dir1", cx);
6944 assert_eq!(
6945 visible_entries_as_strings(&panel, 0..20, cx),
6946 &[
6947 "v root",
6948 " v dir1 <== selected",
6949 " > empty1",
6950 " > ignored_dir",
6951 " > subdir1",
6952 " .gitignore",
6953 ],
6954 "With auto-fold disabled: should show all directories separately"
6955 );
6956
6957 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
6958 panel.update_in(cx, |panel, window, cx| {
6959 let project = panel.project.read(cx);
6960 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6961 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
6962 panel.update_visible_entries(None, false, false, window, cx);
6963 });
6964 cx.run_until_parked();
6965
6966 assert_eq!(
6967 visible_entries_as_strings(&panel, 0..20, cx),
6968 &[
6969 "v root",
6970 " v dir1 <== selected",
6971 " v empty1",
6972 " v empty2",
6973 " v empty3",
6974 " file.txt",
6975 " > ignored_dir",
6976 " v subdir1",
6977 " > ignored_nested",
6978 " file1.txt",
6979 " file2.txt",
6980 " .gitignore",
6981 ],
6982 "After expand_all without auto-fold: should expand all dirs normally, \
6983 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
6984 );
6985
6986 // Test 3: When explicitly called on ignored directory
6987 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
6988 panel.update_in(cx, |panel, window, cx| {
6989 let project = panel.project.read(cx);
6990 let worktree = project.worktrees(cx).next().unwrap().read(cx);
6991 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
6992 panel.update_visible_entries(None, false, false, window, cx);
6993 });
6994 cx.run_until_parked();
6995
6996 assert_eq!(
6997 visible_entries_as_strings(&panel, 0..20, cx),
6998 &[
6999 "v root",
7000 " v dir1 <== selected",
7001 " v empty1",
7002 " v empty2",
7003 " v empty3",
7004 " file.txt",
7005 " v ignored_dir",
7006 " v subdir",
7007 " deep_file.txt",
7008 " v subdir1",
7009 " > ignored_nested",
7010 " file1.txt",
7011 " file2.txt",
7012 " .gitignore",
7013 ],
7014 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
7015 );
7016}
7017
7018#[gpui::test]
7019async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
7020 init_test(cx);
7021
7022 let fs = FakeFs::new(cx.executor());
7023 fs.insert_tree(
7024 path!("/root"),
7025 json!({
7026 "dir1": {
7027 "subdir1": {
7028 "nested1": {
7029 "file1.txt": "",
7030 "file2.txt": ""
7031 },
7032 },
7033 "subdir2": {
7034 "file4.txt": ""
7035 }
7036 },
7037 "dir2": {
7038 "single_file": {
7039 "file5.txt": ""
7040 }
7041 }
7042 }),
7043 )
7044 .await;
7045
7046 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7047 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7048 let workspace = window
7049 .read_with(cx, |mw, _| mw.workspace().clone())
7050 .unwrap();
7051 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7052
7053 // Test 1: Basic collapsing
7054 {
7055 let panel = workspace.update_in(cx, ProjectPanel::new);
7056 cx.run_until_parked();
7057
7058 toggle_expand_dir(&panel, "root/dir1", cx);
7059 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7060 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7061 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7062
7063 assert_eq!(
7064 visible_entries_as_strings(&panel, 0..20, cx),
7065 &[
7066 "v root",
7067 " v dir1",
7068 " v subdir1",
7069 " v nested1",
7070 " file1.txt",
7071 " file2.txt",
7072 " v subdir2 <== selected",
7073 " file4.txt",
7074 " > dir2",
7075 ],
7076 "Initial state with everything expanded"
7077 );
7078
7079 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7080 panel.update_in(cx, |panel, window, cx| {
7081 let project = panel.project.read(cx);
7082 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7083 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7084 panel.update_visible_entries(None, false, false, window, cx);
7085 });
7086 cx.run_until_parked();
7087
7088 assert_eq!(
7089 visible_entries_as_strings(&panel, 0..20, cx),
7090 &["v root", " > dir1", " > dir2",],
7091 "All subdirs under dir1 should be collapsed"
7092 );
7093 }
7094
7095 // Test 2: With auto-fold enabled
7096 {
7097 cx.update(|_, cx| {
7098 let settings = *ProjectPanelSettings::get_global(cx);
7099 ProjectPanelSettings::override_global(
7100 ProjectPanelSettings {
7101 auto_fold_dirs: true,
7102 ..settings
7103 },
7104 cx,
7105 );
7106 });
7107
7108 let panel = workspace.update_in(cx, ProjectPanel::new);
7109 cx.run_until_parked();
7110
7111 toggle_expand_dir(&panel, "root/dir1", cx);
7112 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7113 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7114
7115 assert_eq!(
7116 visible_entries_as_strings(&panel, 0..20, cx),
7117 &[
7118 "v root",
7119 " v dir1",
7120 " v subdir1/nested1 <== selected",
7121 " file1.txt",
7122 " file2.txt",
7123 " > subdir2",
7124 " > dir2/single_file",
7125 ],
7126 "Initial state with some dirs expanded"
7127 );
7128
7129 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7130 panel.update(cx, |panel, cx| {
7131 let project = panel.project.read(cx);
7132 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7133 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7134 });
7135
7136 toggle_expand_dir(&panel, "root/dir1", cx);
7137
7138 assert_eq!(
7139 visible_entries_as_strings(&panel, 0..20, cx),
7140 &[
7141 "v root",
7142 " v dir1 <== selected",
7143 " > subdir1/nested1",
7144 " > subdir2",
7145 " > dir2/single_file",
7146 ],
7147 "Subdirs should be collapsed and folded with auto-fold enabled"
7148 );
7149 }
7150
7151 // Test 3: With auto-fold disabled
7152 {
7153 cx.update(|_, cx| {
7154 let settings = *ProjectPanelSettings::get_global(cx);
7155 ProjectPanelSettings::override_global(
7156 ProjectPanelSettings {
7157 auto_fold_dirs: false,
7158 ..settings
7159 },
7160 cx,
7161 );
7162 });
7163
7164 let panel = workspace.update_in(cx, ProjectPanel::new);
7165 cx.run_until_parked();
7166
7167 toggle_expand_dir(&panel, "root/dir1", cx);
7168 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7169 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7170
7171 assert_eq!(
7172 visible_entries_as_strings(&panel, 0..20, cx),
7173 &[
7174 "v root",
7175 " v dir1",
7176 " v subdir1",
7177 " v nested1 <== selected",
7178 " file1.txt",
7179 " file2.txt",
7180 " > subdir2",
7181 " > dir2",
7182 ],
7183 "Initial state with some dirs expanded and auto-fold disabled"
7184 );
7185
7186 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
7187 panel.update(cx, |panel, cx| {
7188 let project = panel.project.read(cx);
7189 let worktree = project.worktrees(cx).next().unwrap().read(cx);
7190 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
7191 });
7192
7193 toggle_expand_dir(&panel, "root/dir1", cx);
7194
7195 assert_eq!(
7196 visible_entries_as_strings(&panel, 0..20, cx),
7197 &[
7198 "v root",
7199 " v dir1 <== selected",
7200 " > subdir1",
7201 " > subdir2",
7202 " > dir2",
7203 ],
7204 "Subdirs should be collapsed but not folded with auto-fold disabled"
7205 );
7206 }
7207}
7208
7209#[gpui::test]
7210async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestAppContext) {
7211 init_test(cx);
7212
7213 let fs = FakeFs::new(cx.executor());
7214 fs.insert_tree(
7215 path!("/root"),
7216 json!({
7217 "dir1": {
7218 "subdir1": {
7219 "nested1": {
7220 "file1.txt": "",
7221 "file2.txt": ""
7222 },
7223 },
7224 "subdir2": {
7225 "file3.txt": ""
7226 }
7227 },
7228 "dir2": {
7229 "file4.txt": ""
7230 }
7231 }),
7232 )
7233 .await;
7234
7235 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7236 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7237 let workspace = window
7238 .read_with(cx, |mw, _| mw.workspace().clone())
7239 .unwrap();
7240 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7241
7242 let panel = workspace.update_in(cx, ProjectPanel::new);
7243 cx.run_until_parked();
7244
7245 toggle_expand_dir(&panel, "root/dir1", cx);
7246 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7247 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
7248 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
7249 toggle_expand_dir(&panel, "root/dir2", cx);
7250
7251 assert_eq!(
7252 visible_entries_as_strings(&panel, 0..20, cx),
7253 &[
7254 "v root",
7255 " v dir1",
7256 " v subdir1",
7257 " v nested1",
7258 " file1.txt",
7259 " file2.txt",
7260 " v subdir2",
7261 " file3.txt",
7262 " v dir2 <== selected",
7263 " file4.txt",
7264 ],
7265 "Initial state with directories expanded"
7266 );
7267
7268 select_path(&panel, "root/dir1", cx);
7269 cx.run_until_parked();
7270
7271 panel.update_in(cx, |panel, window, cx| {
7272 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7273 });
7274 cx.run_until_parked();
7275
7276 assert_eq!(
7277 visible_entries_as_strings(&panel, 0..20, cx),
7278 &[
7279 "v root",
7280 " > dir1 <== selected",
7281 " v dir2",
7282 " file4.txt",
7283 ],
7284 "dir1 and all its children should be collapsed, dir2 should remain expanded"
7285 );
7286
7287 toggle_expand_dir(&panel, "root/dir1", cx);
7288 cx.run_until_parked();
7289
7290 assert_eq!(
7291 visible_entries_as_strings(&panel, 0..20, cx),
7292 &[
7293 "v root",
7294 " v dir1 <== selected",
7295 " > subdir1",
7296 " > subdir2",
7297 " v dir2",
7298 " file4.txt",
7299 ],
7300 "After re-expanding dir1, its children should still be collapsed"
7301 );
7302}
7303
7304#[gpui::test]
7305async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) {
7306 init_test(cx);
7307
7308 let fs = FakeFs::new(cx.executor());
7309 fs.insert_tree(
7310 path!("/root"),
7311 json!({
7312 "dir1": {
7313 "subdir1": {
7314 "file1.txt": ""
7315 },
7316 "file2.txt": ""
7317 },
7318 "dir2": {
7319 "file3.txt": ""
7320 }
7321 }),
7322 )
7323 .await;
7324
7325 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7326 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7327 let workspace = window
7328 .read_with(cx, |mw, _| mw.workspace().clone())
7329 .unwrap();
7330 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7331
7332 let panel = workspace.update_in(cx, ProjectPanel::new);
7333 cx.run_until_parked();
7334
7335 toggle_expand_dir(&panel, "root/dir1", cx);
7336 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7337 toggle_expand_dir(&panel, "root/dir2", cx);
7338
7339 assert_eq!(
7340 visible_entries_as_strings(&panel, 0..20, cx),
7341 &[
7342 "v root",
7343 " v dir1",
7344 " v subdir1",
7345 " file1.txt",
7346 " file2.txt",
7347 " v dir2 <== selected",
7348 " file3.txt",
7349 ],
7350 "Initial state with directories expanded"
7351 );
7352
7353 // Select the root and collapse it and its children
7354 select_path(&panel, "root", cx);
7355 cx.run_until_parked();
7356
7357 panel.update_in(cx, |panel, window, cx| {
7358 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7359 });
7360 cx.run_until_parked();
7361
7362 // The root and all its children should be collapsed
7363 assert_eq!(
7364 visible_entries_as_strings(&panel, 0..20, cx),
7365 &["> root <== selected"],
7366 "Root and all children should be collapsed"
7367 );
7368
7369 // Re-expand root and dir1, verify children were recursively collapsed
7370 toggle_expand_dir(&panel, "root", cx);
7371 toggle_expand_dir(&panel, "root/dir1", cx);
7372 cx.run_until_parked();
7373
7374 assert_eq!(
7375 visible_entries_as_strings(&panel, 0..20, cx),
7376 &[
7377 "v root",
7378 " v dir1 <== selected",
7379 " > subdir1",
7380 " file2.txt",
7381 " > dir2",
7382 ],
7383 "After re-expanding root and dir1, subdir1 should still be collapsed"
7384 );
7385}
7386
7387#[gpui::test]
7388async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7389 init_test(cx);
7390
7391 let fs = FakeFs::new(cx.executor());
7392 fs.insert_tree(
7393 path!("/root1"),
7394 json!({
7395 "dir1": {
7396 "subdir1": {
7397 "file1.txt": ""
7398 },
7399 "file2.txt": ""
7400 }
7401 }),
7402 )
7403 .await;
7404 fs.insert_tree(
7405 path!("/root2"),
7406 json!({
7407 "dir2": {
7408 "file3.txt": ""
7409 },
7410 "file4.txt": ""
7411 }),
7412 )
7413 .await;
7414
7415 let project = Project::test(
7416 fs.clone(),
7417 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7418 cx,
7419 )
7420 .await;
7421 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7422 let workspace = window
7423 .read_with(cx, |mw, _| mw.workspace().clone())
7424 .unwrap();
7425 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7426
7427 let panel = workspace.update_in(cx, ProjectPanel::new);
7428 cx.run_until_parked();
7429
7430 toggle_expand_dir(&panel, "root1/dir1", cx);
7431 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7432 toggle_expand_dir(&panel, "root2/dir2", cx);
7433
7434 assert_eq!(
7435 visible_entries_as_strings(&panel, 0..20, cx),
7436 &[
7437 "v root1",
7438 " v dir1",
7439 " v subdir1",
7440 " file1.txt",
7441 " file2.txt",
7442 "v root2",
7443 " v dir2 <== selected",
7444 " file3.txt",
7445 " file4.txt",
7446 ],
7447 "Initial state with directories expanded across worktrees"
7448 );
7449
7450 // Select root1 and collapse it and its children.
7451 // In a multi-worktree project, this should only collapse the selected worktree,
7452 // leaving other worktrees unaffected.
7453 select_path(&panel, "root1", cx);
7454 cx.run_until_parked();
7455
7456 panel.update_in(cx, |panel, window, cx| {
7457 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7458 });
7459 cx.run_until_parked();
7460
7461 assert_eq!(
7462 visible_entries_as_strings(&panel, 0..20, cx),
7463 &[
7464 "> root1 <== selected",
7465 "v root2",
7466 " v dir2",
7467 " file3.txt",
7468 " file4.txt",
7469 ],
7470 "Only root1 should be collapsed, root2 should remain expanded"
7471 );
7472
7473 // Re-expand root1 and verify its children were recursively collapsed
7474 toggle_expand_dir(&panel, "root1", cx);
7475
7476 assert_eq!(
7477 visible_entries_as_strings(&panel, 0..20, cx),
7478 &[
7479 "v root1 <== selected",
7480 " > dir1",
7481 "v root2",
7482 " v dir2",
7483 " file3.txt",
7484 " file4.txt",
7485 ],
7486 "After re-expanding root1, dir1 should still be collapsed, root2 should be unaffected"
7487 );
7488}
7489
7490#[gpui::test]
7491async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7492 init_test(cx);
7493
7494 let fs = FakeFs::new(cx.executor());
7495 fs.insert_tree(
7496 path!("/root1"),
7497 json!({
7498 "dir1": {
7499 "subdir1": {
7500 "file1.txt": ""
7501 },
7502 "file2.txt": ""
7503 }
7504 }),
7505 )
7506 .await;
7507 fs.insert_tree(
7508 path!("/root2"),
7509 json!({
7510 "dir2": {
7511 "subdir2": {
7512 "file3.txt": ""
7513 },
7514 "file4.txt": ""
7515 }
7516 }),
7517 )
7518 .await;
7519
7520 let project = Project::test(
7521 fs.clone(),
7522 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7523 cx,
7524 )
7525 .await;
7526 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7527 let workspace = window
7528 .read_with(cx, |mw, _| mw.workspace().clone())
7529 .unwrap();
7530 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7531
7532 let panel = workspace.update_in(cx, ProjectPanel::new);
7533 cx.run_until_parked();
7534
7535 toggle_expand_dir(&panel, "root1/dir1", cx);
7536 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7537 toggle_expand_dir(&panel, "root2/dir2", cx);
7538 toggle_expand_dir(&panel, "root2/dir2/subdir2", cx);
7539
7540 assert_eq!(
7541 visible_entries_as_strings(&panel, 0..20, cx),
7542 &[
7543 "v root1",
7544 " v dir1",
7545 " v subdir1",
7546 " file1.txt",
7547 " file2.txt",
7548 "v root2",
7549 " v dir2",
7550 " v subdir2 <== selected",
7551 " file3.txt",
7552 " file4.txt",
7553 ],
7554 "Initial state with directories expanded across worktrees"
7555 );
7556
7557 // Select dir1 in root1 and collapse it
7558 select_path(&panel, "root1/dir1", cx);
7559 cx.run_until_parked();
7560
7561 panel.update_in(cx, |panel, window, cx| {
7562 panel.collapse_selected_entry_and_children(&CollapseSelectedEntryAndChildren, window, cx);
7563 });
7564 cx.run_until_parked();
7565
7566 assert_eq!(
7567 visible_entries_as_strings(&panel, 0..20, cx),
7568 &[
7569 "v root1",
7570 " > dir1 <== selected",
7571 "v root2",
7572 " v dir2",
7573 " v subdir2",
7574 " file3.txt",
7575 " file4.txt",
7576 ],
7577 "Only dir1 should be collapsed, root2 should be completely unaffected"
7578 );
7579
7580 // Re-expand dir1 and verify subdir1 was recursively collapsed
7581 toggle_expand_dir(&panel, "root1/dir1", cx);
7582
7583 assert_eq!(
7584 visible_entries_as_strings(&panel, 0..20, cx),
7585 &[
7586 "v root1",
7587 " v dir1 <== selected",
7588 " > subdir1",
7589 " file2.txt",
7590 "v root2",
7591 " v dir2",
7592 " v subdir2",
7593 " file3.txt",
7594 " file4.txt",
7595 ],
7596 "After re-expanding dir1, subdir1 should still be collapsed"
7597 );
7598}
7599
7600#[gpui::test]
7601async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContext) {
7602 init_test(cx);
7603
7604 let fs = FakeFs::new(cx.executor());
7605 fs.insert_tree(
7606 path!("/root"),
7607 json!({
7608 "dir1": {
7609 "subdir1": {
7610 "file1.txt": ""
7611 },
7612 "file2.txt": ""
7613 },
7614 "dir2": {
7615 "file3.txt": ""
7616 }
7617 }),
7618 )
7619 .await;
7620
7621 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7622 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7623 let workspace = window
7624 .read_with(cx, |mw, _| mw.workspace().clone())
7625 .unwrap();
7626 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7627
7628 let panel = workspace.update_in(cx, ProjectPanel::new);
7629 cx.run_until_parked();
7630
7631 toggle_expand_dir(&panel, "root/dir1", cx);
7632 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7633 toggle_expand_dir(&panel, "root/dir2", cx);
7634
7635 assert_eq!(
7636 visible_entries_as_strings(&panel, 0..20, cx),
7637 &[
7638 "v root",
7639 " v dir1",
7640 " v subdir1",
7641 " file1.txt",
7642 " file2.txt",
7643 " v dir2 <== selected",
7644 " file3.txt",
7645 ],
7646 "Initial state with directories expanded"
7647 );
7648
7649 select_path(&panel, "root", cx);
7650 cx.run_until_parked();
7651
7652 panel.update_in(cx, |panel, window, cx| {
7653 panel.collapse_all_for_root(window, cx);
7654 });
7655 cx.run_until_parked();
7656
7657 assert_eq!(
7658 visible_entries_as_strings(&panel, 0..20, cx),
7659 &["v root <== selected", " > dir1", " > dir2"],
7660 "Root should remain expanded but all children should be collapsed"
7661 );
7662
7663 toggle_expand_dir(&panel, "root/dir1", cx);
7664 cx.run_until_parked();
7665
7666 assert_eq!(
7667 visible_entries_as_strings(&panel, 0..20, cx),
7668 &[
7669 "v root",
7670 " v dir1 <== selected",
7671 " > subdir1",
7672 " file2.txt",
7673 " > dir2",
7674 ],
7675 "After re-expanding dir1, subdir1 should still be collapsed"
7676 );
7677}
7678
7679#[gpui::test]
7680async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext) {
7681 init_test(cx);
7682
7683 let fs = FakeFs::new(cx.executor());
7684 fs.insert_tree(
7685 path!("/root1"),
7686 json!({
7687 "dir1": {
7688 "subdir1": {
7689 "file1.txt": ""
7690 },
7691 "file2.txt": ""
7692 }
7693 }),
7694 )
7695 .await;
7696 fs.insert_tree(
7697 path!("/root2"),
7698 json!({
7699 "dir2": {
7700 "file3.txt": ""
7701 },
7702 "file4.txt": ""
7703 }),
7704 )
7705 .await;
7706
7707 let project = Project::test(
7708 fs.clone(),
7709 [path!("/root1").as_ref(), path!("/root2").as_ref()],
7710 cx,
7711 )
7712 .await;
7713 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7714 let workspace = window
7715 .read_with(cx, |mw, _| mw.workspace().clone())
7716 .unwrap();
7717 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7718
7719 let panel = workspace.update_in(cx, ProjectPanel::new);
7720 cx.run_until_parked();
7721
7722 toggle_expand_dir(&panel, "root1/dir1", cx);
7723 toggle_expand_dir(&panel, "root1/dir1/subdir1", cx);
7724 toggle_expand_dir(&panel, "root2/dir2", cx);
7725
7726 assert_eq!(
7727 visible_entries_as_strings(&panel, 0..20, cx),
7728 &[
7729 "v root1",
7730 " v dir1",
7731 " v subdir1",
7732 " file1.txt",
7733 " file2.txt",
7734 "v root2",
7735 " v dir2 <== selected",
7736 " file3.txt",
7737 " file4.txt",
7738 ],
7739 "Initial state with directories expanded across worktrees"
7740 );
7741
7742 select_path(&panel, "root1", cx);
7743 cx.run_until_parked();
7744
7745 panel.update_in(cx, |panel, window, cx| {
7746 panel.collapse_all_for_root(window, cx);
7747 });
7748 cx.run_until_parked();
7749
7750 assert_eq!(
7751 visible_entries_as_strings(&panel, 0..20, cx),
7752 &[
7753 "> root1 <== selected",
7754 "v root2",
7755 " v dir2",
7756 " file3.txt",
7757 " file4.txt",
7758 ],
7759 "With multiple worktrees, root1 should collapse completely (including itself)"
7760 );
7761}
7762
7763#[gpui::test]
7764async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppContext) {
7765 init_test(cx);
7766
7767 let fs = FakeFs::new(cx.executor());
7768 fs.insert_tree(
7769 path!("/root"),
7770 json!({
7771 "dir1": {
7772 "subdir1": {
7773 "file1.txt": ""
7774 },
7775 },
7776 "dir2": {
7777 "file2.txt": ""
7778 }
7779 }),
7780 )
7781 .await;
7782
7783 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7784 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7785 let workspace = window
7786 .read_with(cx, |mw, _| mw.workspace().clone())
7787 .unwrap();
7788 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7789
7790 let panel = workspace.update_in(cx, ProjectPanel::new);
7791 cx.run_until_parked();
7792
7793 toggle_expand_dir(&panel, "root/dir1", cx);
7794 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7795 toggle_expand_dir(&panel, "root/dir2", cx);
7796
7797 assert_eq!(
7798 visible_entries_as_strings(&panel, 0..20, cx),
7799 &[
7800 "v root",
7801 " v dir1",
7802 " v subdir1",
7803 " file1.txt",
7804 " v dir2 <== selected",
7805 " file2.txt",
7806 ],
7807 "Initial state with directories expanded"
7808 );
7809
7810 select_path(&panel, "root/dir1", cx);
7811 cx.run_until_parked();
7812
7813 panel.update_in(cx, |panel, window, cx| {
7814 panel.collapse_all_for_root(window, cx);
7815 });
7816 cx.run_until_parked();
7817
7818 assert_eq!(
7819 visible_entries_as_strings(&panel, 0..20, cx),
7820 &[
7821 "v root",
7822 " v dir1 <== selected",
7823 " v subdir1",
7824 " file1.txt",
7825 " v dir2",
7826 " file2.txt",
7827 ],
7828 "collapse_all_for_root should be a no-op when called on a non-root directory"
7829 );
7830}
7831
7832#[gpui::test]
7833async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
7834 init_test(cx);
7835
7836 let fs = FakeFs::new(cx.executor());
7837 fs.insert_tree(
7838 path!("/root"),
7839 json!({
7840 "dir1": {
7841 "file1.txt": "",
7842 },
7843 }),
7844 )
7845 .await;
7846
7847 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7848 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7849 let workspace = window
7850 .read_with(cx, |mw, _| mw.workspace().clone())
7851 .unwrap();
7852 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7853
7854 let panel = workspace.update_in(cx, |workspace, window, cx| {
7855 let panel = ProjectPanel::new(workspace, window, cx);
7856 workspace.add_panel(panel.clone(), window, cx);
7857 panel
7858 });
7859 cx.run_until_parked();
7860
7861 #[rustfmt::skip]
7862 assert_eq!(
7863 visible_entries_as_strings(&panel, 0..20, cx),
7864 &[
7865 "v root",
7866 " > dir1",
7867 ],
7868 "Initial state with nothing selected"
7869 );
7870
7871 panel.update_in(cx, |panel, window, cx| {
7872 panel.new_file(&NewFile, window, cx);
7873 });
7874 cx.run_until_parked();
7875 panel.update_in(cx, |panel, window, cx| {
7876 assert!(panel.filename_editor.read(cx).is_focused(window));
7877 });
7878 panel
7879 .update_in(cx, |panel, window, cx| {
7880 panel.filename_editor.update(cx, |editor, cx| {
7881 editor.set_text("hello_from_no_selections", window, cx)
7882 });
7883 panel.confirm_edit(true, window, cx).unwrap()
7884 })
7885 .await
7886 .unwrap();
7887 cx.run_until_parked();
7888 #[rustfmt::skip]
7889 assert_eq!(
7890 visible_entries_as_strings(&panel, 0..20, cx),
7891 &[
7892 "v root",
7893 " > dir1",
7894 " hello_from_no_selections <== selected <== marked",
7895 ],
7896 "A new file is created under the root directory"
7897 );
7898}
7899
7900#[gpui::test]
7901async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppContext) {
7902 init_test(cx);
7903
7904 let fs = FakeFs::new(cx.executor());
7905 fs.insert_tree(
7906 path!("/root"),
7907 json!({
7908 "existing_dir": {
7909 "existing_file.txt": "",
7910 },
7911 "existing_file.txt": "",
7912 }),
7913 )
7914 .await;
7915
7916 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
7917 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7918 let workspace = window
7919 .read_with(cx, |mw, _| mw.workspace().clone())
7920 .unwrap();
7921 let cx = &mut VisualTestContext::from_window(window.into(), cx);
7922
7923 cx.update(|_, cx| {
7924 let settings = *ProjectPanelSettings::get_global(cx);
7925 ProjectPanelSettings::override_global(
7926 ProjectPanelSettings {
7927 hide_root: true,
7928 ..settings
7929 },
7930 cx,
7931 );
7932 });
7933
7934 let panel = workspace.update_in(cx, |workspace, window, cx| {
7935 let panel = ProjectPanel::new(workspace, window, cx);
7936 workspace.add_panel(panel.clone(), window, cx);
7937 panel
7938 });
7939 cx.run_until_parked();
7940
7941 #[rustfmt::skip]
7942 assert_eq!(
7943 visible_entries_as_strings(&panel, 0..20, cx),
7944 &[
7945 "> existing_dir",
7946 " existing_file.txt",
7947 ],
7948 "Initial state with hide_root=true, root should be hidden and nothing selected"
7949 );
7950
7951 panel.update(cx, |panel, _| {
7952 assert!(
7953 panel.selection.is_none(),
7954 "Should have no selection initially"
7955 );
7956 });
7957
7958 // Test 1: Create new file when no entry is selected
7959 panel.update_in(cx, |panel, window, cx| {
7960 panel.new_file(&NewFile, window, cx);
7961 });
7962 cx.run_until_parked();
7963 panel.update_in(cx, |panel, window, cx| {
7964 assert!(panel.filename_editor.read(cx).is_focused(window));
7965 });
7966 cx.run_until_parked();
7967 #[rustfmt::skip]
7968 assert_eq!(
7969 visible_entries_as_strings(&panel, 0..20, cx),
7970 &[
7971 "> existing_dir",
7972 " [EDITOR: ''] <== selected",
7973 " existing_file.txt",
7974 ],
7975 "Editor should appear at root level when hide_root=true and no selection"
7976 );
7977
7978 let confirm = panel.update_in(cx, |panel, window, cx| {
7979 panel.filename_editor.update(cx, |editor, cx| {
7980 editor.set_text("new_file_at_root.txt", window, cx)
7981 });
7982 panel.confirm_edit(true, window, cx).unwrap()
7983 });
7984 confirm.await.unwrap();
7985 cx.run_until_parked();
7986
7987 #[rustfmt::skip]
7988 assert_eq!(
7989 visible_entries_as_strings(&panel, 0..20, cx),
7990 &[
7991 "> existing_dir",
7992 " existing_file.txt",
7993 " new_file_at_root.txt <== selected <== marked",
7994 ],
7995 "New file should be created at root level and visible without root prefix"
7996 );
7997
7998 assert!(
7999 fs.is_file(Path::new("/root/new_file_at_root.txt")).await,
8000 "File should be created in the actual root directory"
8001 );
8002
8003 // Test 2: Create new directory when no entry is selected
8004 panel.update(cx, |panel, _| {
8005 panel.selection = None;
8006 });
8007
8008 panel.update_in(cx, |panel, window, cx| {
8009 panel.new_directory(&NewDirectory, window, cx);
8010 });
8011 cx.run_until_parked();
8012
8013 panel.update_in(cx, |panel, window, cx| {
8014 assert!(panel.filename_editor.read(cx).is_focused(window));
8015 });
8016
8017 #[rustfmt::skip]
8018 assert_eq!(
8019 visible_entries_as_strings(&panel, 0..20, cx),
8020 &[
8021 "> [EDITOR: ''] <== selected",
8022 "> existing_dir",
8023 " existing_file.txt",
8024 " new_file_at_root.txt",
8025 ],
8026 "Directory editor should appear at root level when hide_root=true and no selection"
8027 );
8028
8029 let confirm = panel.update_in(cx, |panel, window, cx| {
8030 panel.filename_editor.update(cx, |editor, cx| {
8031 editor.set_text("new_dir_at_root", window, cx)
8032 });
8033 panel.confirm_edit(true, window, cx).unwrap()
8034 });
8035 confirm.await.unwrap();
8036 cx.run_until_parked();
8037
8038 #[rustfmt::skip]
8039 assert_eq!(
8040 visible_entries_as_strings(&panel, 0..20, cx),
8041 &[
8042 "> existing_dir",
8043 "v new_dir_at_root <== selected",
8044 " existing_file.txt",
8045 " new_file_at_root.txt",
8046 ],
8047 "New directory should be created at root level and visible without root prefix"
8048 );
8049
8050 assert!(
8051 fs.is_dir(Path::new("/root/new_dir_at_root")).await,
8052 "Directory should be created in the actual root directory"
8053 );
8054}
8055
8056#[cfg(windows)]
8057#[gpui::test]
8058async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppContext) {
8059 init_test(cx);
8060
8061 let fs = FakeFs::new(cx.executor());
8062 fs.insert_tree(
8063 path!("/root"),
8064 json!({
8065 "dir1": {
8066 "file1.txt": "",
8067 },
8068 }),
8069 )
8070 .await;
8071
8072 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8073 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8074 let workspace = window
8075 .read_with(cx, |mw, _| mw.workspace().clone())
8076 .unwrap();
8077 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8078
8079 let panel = workspace.update_in(cx, |workspace, window, cx| {
8080 let panel = ProjectPanel::new(workspace, window, cx);
8081 workspace.add_panel(panel.clone(), window, cx);
8082 panel
8083 });
8084 cx.run_until_parked();
8085
8086 #[rustfmt::skip]
8087 assert_eq!(
8088 visible_entries_as_strings(&panel, 0..20, cx),
8089 &[
8090 "v root",
8091 " > dir1",
8092 ],
8093 "Initial state with nothing selected"
8094 );
8095
8096 panel.update_in(cx, |panel, window, cx| {
8097 panel.new_file(&NewFile, window, cx);
8098 });
8099 cx.run_until_parked();
8100 panel.update_in(cx, |panel, window, cx| {
8101 assert!(panel.filename_editor.read(cx).is_focused(window));
8102 });
8103 panel
8104 .update_in(cx, |panel, window, cx| {
8105 panel
8106 .filename_editor
8107 .update(cx, |editor, cx| editor.set_text("foo.", window, cx));
8108 panel.confirm_edit(true, window, cx).unwrap()
8109 })
8110 .await
8111 .unwrap();
8112 cx.run_until_parked();
8113 #[rustfmt::skip]
8114 assert_eq!(
8115 visible_entries_as_strings(&panel, 0..20, cx),
8116 &[
8117 "v root",
8118 " > dir1",
8119 " foo <== selected <== marked",
8120 ],
8121 "A new file is created under the root directory without the trailing dot"
8122 );
8123}
8124
8125#[gpui::test]
8126async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) {
8127 init_test(cx);
8128
8129 let fs = FakeFs::new(cx.executor());
8130 fs.insert_tree(
8131 "/root",
8132 json!({
8133 "dir1": {
8134 "file1.txt": "",
8135 "dir2": {
8136 "file2.txt": ""
8137 }
8138 },
8139 "file3.txt": ""
8140 }),
8141 )
8142 .await;
8143
8144 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8145 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8146 let workspace = window
8147 .read_with(cx, |mw, _| mw.workspace().clone())
8148 .unwrap();
8149 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8150 let panel = workspace.update_in(cx, ProjectPanel::new);
8151 cx.run_until_parked();
8152
8153 panel.update(cx, |panel, cx| {
8154 let project = panel.project.read(cx);
8155 let worktree = project.visible_worktrees(cx).next().unwrap();
8156 let worktree = worktree.read(cx);
8157
8158 // Test 1: Target is a directory, should highlight the directory itself
8159 let dir_entry = worktree.entry_for_path(rel_path("dir1")).unwrap();
8160 let result = panel.highlight_entry_for_external_drag(dir_entry, worktree);
8161 assert_eq!(
8162 result,
8163 Some(dir_entry.id),
8164 "Should highlight directory itself"
8165 );
8166
8167 // Test 2: Target is nested file, should highlight immediate parent
8168 let nested_file = worktree
8169 .entry_for_path(rel_path("dir1/dir2/file2.txt"))
8170 .unwrap();
8171 let nested_parent = worktree.entry_for_path(rel_path("dir1/dir2")).unwrap();
8172 let result = panel.highlight_entry_for_external_drag(nested_file, worktree);
8173 assert_eq!(
8174 result,
8175 Some(nested_parent.id),
8176 "Should highlight immediate parent"
8177 );
8178
8179 // Test 3: Target is root level file, should highlight root
8180 let root_file = worktree.entry_for_path(rel_path("file3.txt")).unwrap();
8181 let result = panel.highlight_entry_for_external_drag(root_file, worktree);
8182 assert_eq!(
8183 result,
8184 Some(worktree.root_entry().unwrap().id),
8185 "Root level file should return None"
8186 );
8187
8188 // Test 4: Target is root itself, should highlight root
8189 let root_entry = worktree.root_entry().unwrap();
8190 let result = panel.highlight_entry_for_external_drag(root_entry, worktree);
8191 assert_eq!(
8192 result,
8193 Some(root_entry.id),
8194 "Root level file should return None"
8195 );
8196 });
8197}
8198
8199#[gpui::test]
8200async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) {
8201 init_test(cx);
8202
8203 let fs = FakeFs::new(cx.executor());
8204 fs.insert_tree(
8205 "/root",
8206 json!({
8207 "parent_dir": {
8208 "child_file.txt": "",
8209 "sibling_file.txt": "",
8210 "child_dir": {
8211 "nested_file.txt": ""
8212 }
8213 },
8214 "other_dir": {
8215 "other_file.txt": ""
8216 }
8217 }),
8218 )
8219 .await;
8220
8221 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8222 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8223 let workspace = window
8224 .read_with(cx, |mw, _| mw.workspace().clone())
8225 .unwrap();
8226 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8227 let panel = workspace.update_in(cx, ProjectPanel::new);
8228 cx.run_until_parked();
8229
8230 panel.update(cx, |panel, cx| {
8231 let project = panel.project.read(cx);
8232 let worktree = project.visible_worktrees(cx).next().unwrap();
8233 let worktree_id = worktree.read(cx).id();
8234 let worktree = worktree.read(cx);
8235
8236 let parent_dir = worktree.entry_for_path(rel_path("parent_dir")).unwrap();
8237 let child_file = worktree
8238 .entry_for_path(rel_path("parent_dir/child_file.txt"))
8239 .unwrap();
8240 let sibling_file = worktree
8241 .entry_for_path(rel_path("parent_dir/sibling_file.txt"))
8242 .unwrap();
8243 let child_dir = worktree
8244 .entry_for_path(rel_path("parent_dir/child_dir"))
8245 .unwrap();
8246 let other_dir = worktree.entry_for_path(rel_path("other_dir")).unwrap();
8247 let other_file = worktree
8248 .entry_for_path(rel_path("other_dir/other_file.txt"))
8249 .unwrap();
8250
8251 // Test 1: Single item drag, don't highlight parent directory
8252 let dragged_selection = DraggedSelection {
8253 active_selection: SelectedEntry {
8254 worktree_id,
8255 entry_id: child_file.id,
8256 },
8257 marked_selections: Arc::new([SelectedEntry {
8258 worktree_id,
8259 entry_id: child_file.id,
8260 }]),
8261 };
8262 let result =
8263 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
8264 assert_eq!(result, None, "Should not highlight parent of dragged item");
8265
8266 // Test 2: Single item drag, don't highlight sibling files
8267 let result = panel.highlight_entry_for_selection_drag(
8268 sibling_file,
8269 worktree,
8270 &dragged_selection,
8271 cx,
8272 );
8273 assert_eq!(result, None, "Should not highlight sibling files");
8274
8275 // Test 3: Single item drag, highlight unrelated directory
8276 let result =
8277 panel.highlight_entry_for_selection_drag(other_dir, worktree, &dragged_selection, cx);
8278 assert_eq!(
8279 result,
8280 Some(other_dir.id),
8281 "Should highlight unrelated directory"
8282 );
8283
8284 // Test 4: Single item drag, highlight sibling directory
8285 let result =
8286 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
8287 assert_eq!(
8288 result,
8289 Some(child_dir.id),
8290 "Should highlight sibling directory"
8291 );
8292
8293 // Test 5: Multiple items drag, highlight parent directory
8294 let dragged_selection = DraggedSelection {
8295 active_selection: SelectedEntry {
8296 worktree_id,
8297 entry_id: child_file.id,
8298 },
8299 marked_selections: Arc::new([
8300 SelectedEntry {
8301 worktree_id,
8302 entry_id: child_file.id,
8303 },
8304 SelectedEntry {
8305 worktree_id,
8306 entry_id: sibling_file.id,
8307 },
8308 ]),
8309 };
8310 let result =
8311 panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
8312 assert_eq!(
8313 result,
8314 Some(parent_dir.id),
8315 "Should highlight parent with multiple items"
8316 );
8317
8318 // Test 6: Target is file in different directory, highlight parent
8319 let result =
8320 panel.highlight_entry_for_selection_drag(other_file, worktree, &dragged_selection, cx);
8321 assert_eq!(
8322 result,
8323 Some(other_dir.id),
8324 "Should highlight parent of target file"
8325 );
8326
8327 // Test 7: Target is directory, always highlight
8328 let result =
8329 panel.highlight_entry_for_selection_drag(child_dir, worktree, &dragged_selection, cx);
8330 assert_eq!(
8331 result,
8332 Some(child_dir.id),
8333 "Should always highlight directories"
8334 );
8335 });
8336}
8337
8338#[gpui::test]
8339async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::TestAppContext) {
8340 init_test(cx);
8341
8342 let fs = FakeFs::new(cx.executor());
8343 fs.insert_tree(
8344 "/root1",
8345 json!({
8346 "src": {
8347 "main.rs": "",
8348 "lib.rs": ""
8349 }
8350 }),
8351 )
8352 .await;
8353 fs.insert_tree(
8354 "/root2",
8355 json!({
8356 "src": {
8357 "main.rs": "",
8358 "test.rs": ""
8359 }
8360 }),
8361 )
8362 .await;
8363
8364 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8365 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8366 let workspace = window
8367 .read_with(cx, |mw, _| mw.workspace().clone())
8368 .unwrap();
8369 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8370 let panel = workspace.update_in(cx, ProjectPanel::new);
8371 cx.run_until_parked();
8372
8373 panel.update(cx, |panel, cx| {
8374 let project = panel.project.read(cx);
8375 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8376
8377 let worktree_a = &worktrees[0];
8378 let main_rs_from_a = worktree_a
8379 .read(cx)
8380 .entry_for_path(rel_path("src/main.rs"))
8381 .unwrap();
8382
8383 let worktree_b = &worktrees[1];
8384 let src_dir_from_b = worktree_b.read(cx).entry_for_path(rel_path("src")).unwrap();
8385 let main_rs_from_b = worktree_b
8386 .read(cx)
8387 .entry_for_path(rel_path("src/main.rs"))
8388 .unwrap();
8389
8390 // Test dragging file from worktree A onto parent of file with same relative path in worktree B
8391 let dragged_selection = DraggedSelection {
8392 active_selection: SelectedEntry {
8393 worktree_id: worktree_a.read(cx).id(),
8394 entry_id: main_rs_from_a.id,
8395 },
8396 marked_selections: Arc::new([SelectedEntry {
8397 worktree_id: worktree_a.read(cx).id(),
8398 entry_id: main_rs_from_a.id,
8399 }]),
8400 };
8401
8402 let result = panel.highlight_entry_for_selection_drag(
8403 src_dir_from_b,
8404 worktree_b.read(cx),
8405 &dragged_selection,
8406 cx,
8407 );
8408 assert_eq!(
8409 result,
8410 Some(src_dir_from_b.id),
8411 "Should highlight target directory from different worktree even with same relative path"
8412 );
8413
8414 // Test dragging file from worktree A onto file with same relative path in worktree B
8415 let result = panel.highlight_entry_for_selection_drag(
8416 main_rs_from_b,
8417 worktree_b.read(cx),
8418 &dragged_selection,
8419 cx,
8420 );
8421 assert_eq!(
8422 result,
8423 Some(src_dir_from_b.id),
8424 "Should highlight parent of target file from different worktree"
8425 );
8426 });
8427}
8428
8429#[gpui::test]
8430async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::TestAppContext) {
8431 init_test(cx);
8432
8433 let fs = FakeFs::new(cx.executor());
8434 fs.insert_tree(
8435 "/root1",
8436 json!({
8437 "parent_dir": {
8438 "child_file.txt": "",
8439 "nested_dir": {
8440 "nested_file.txt": ""
8441 }
8442 },
8443 "root_file.txt": ""
8444 }),
8445 )
8446 .await;
8447
8448 fs.insert_tree(
8449 "/root2",
8450 json!({
8451 "other_dir": {
8452 "other_file.txt": ""
8453 }
8454 }),
8455 )
8456 .await;
8457
8458 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8459 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8460 let workspace = window
8461 .read_with(cx, |mw, _| mw.workspace().clone())
8462 .unwrap();
8463 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8464 let panel = workspace.update_in(cx, ProjectPanel::new);
8465 cx.run_until_parked();
8466
8467 panel.update(cx, |panel, cx| {
8468 let project = panel.project.read(cx);
8469 let worktrees: Vec<_> = project.visible_worktrees(cx).collect();
8470 let worktree1 = worktrees[0].read(cx);
8471 let worktree2 = worktrees[1].read(cx);
8472 let worktree1_id = worktree1.id();
8473 let _worktree2_id = worktree2.id();
8474
8475 let root1_entry = worktree1.root_entry().unwrap();
8476 let root2_entry = worktree2.root_entry().unwrap();
8477 let _parent_dir = worktree1.entry_for_path(rel_path("parent_dir")).unwrap();
8478 let child_file = worktree1
8479 .entry_for_path(rel_path("parent_dir/child_file.txt"))
8480 .unwrap();
8481 let nested_file = worktree1
8482 .entry_for_path(rel_path("parent_dir/nested_dir/nested_file.txt"))
8483 .unwrap();
8484 let root_file = worktree1.entry_for_path(rel_path("root_file.txt")).unwrap();
8485
8486 // Test 1: Multiple entries - should always highlight background
8487 let multiple_dragged_selection = DraggedSelection {
8488 active_selection: SelectedEntry {
8489 worktree_id: worktree1_id,
8490 entry_id: child_file.id,
8491 },
8492 marked_selections: Arc::new([
8493 SelectedEntry {
8494 worktree_id: worktree1_id,
8495 entry_id: child_file.id,
8496 },
8497 SelectedEntry {
8498 worktree_id: worktree1_id,
8499 entry_id: nested_file.id,
8500 },
8501 ]),
8502 };
8503
8504 let result = panel.should_highlight_background_for_selection_drag(
8505 &multiple_dragged_selection,
8506 root1_entry.id,
8507 cx,
8508 );
8509 assert!(result, "Should highlight background for multiple entries");
8510
8511 // Test 2: Single entry with non-empty parent path - should highlight background
8512 let nested_dragged_selection = DraggedSelection {
8513 active_selection: SelectedEntry {
8514 worktree_id: worktree1_id,
8515 entry_id: nested_file.id,
8516 },
8517 marked_selections: Arc::new([SelectedEntry {
8518 worktree_id: worktree1_id,
8519 entry_id: nested_file.id,
8520 }]),
8521 };
8522
8523 let result = panel.should_highlight_background_for_selection_drag(
8524 &nested_dragged_selection,
8525 root1_entry.id,
8526 cx,
8527 );
8528 assert!(result, "Should highlight background for nested file");
8529
8530 // Test 3: Single entry at root level, same worktree - should NOT highlight background
8531 let root_file_dragged_selection = DraggedSelection {
8532 active_selection: SelectedEntry {
8533 worktree_id: worktree1_id,
8534 entry_id: root_file.id,
8535 },
8536 marked_selections: Arc::new([SelectedEntry {
8537 worktree_id: worktree1_id,
8538 entry_id: root_file.id,
8539 }]),
8540 };
8541
8542 let result = panel.should_highlight_background_for_selection_drag(
8543 &root_file_dragged_selection,
8544 root1_entry.id,
8545 cx,
8546 );
8547 assert!(
8548 !result,
8549 "Should NOT highlight background for root file in same worktree"
8550 );
8551
8552 // Test 4: Single entry at root level, different worktree - should highlight background
8553 let result = panel.should_highlight_background_for_selection_drag(
8554 &root_file_dragged_selection,
8555 root2_entry.id,
8556 cx,
8557 );
8558 assert!(
8559 result,
8560 "Should highlight background for root file from different worktree"
8561 );
8562
8563 // Test 5: Single entry in subdirectory - should highlight background
8564 let child_file_dragged_selection = DraggedSelection {
8565 active_selection: SelectedEntry {
8566 worktree_id: worktree1_id,
8567 entry_id: child_file.id,
8568 },
8569 marked_selections: Arc::new([SelectedEntry {
8570 worktree_id: worktree1_id,
8571 entry_id: child_file.id,
8572 }]),
8573 };
8574
8575 let result = panel.should_highlight_background_for_selection_drag(
8576 &child_file_dragged_selection,
8577 root1_entry.id,
8578 cx,
8579 );
8580 assert!(
8581 result,
8582 "Should highlight background for file with non-empty parent path"
8583 );
8584 });
8585}
8586
8587#[gpui::test]
8588async fn test_hide_root(cx: &mut gpui::TestAppContext) {
8589 init_test(cx);
8590
8591 let fs = FakeFs::new(cx.executor());
8592 fs.insert_tree(
8593 "/root1",
8594 json!({
8595 "dir1": {
8596 "file1.txt": "content",
8597 "file2.txt": "content",
8598 },
8599 "dir2": {
8600 "file3.txt": "content",
8601 },
8602 "file4.txt": "content",
8603 }),
8604 )
8605 .await;
8606
8607 fs.insert_tree(
8608 "/root2",
8609 json!({
8610 "dir3": {
8611 "file5.txt": "content",
8612 },
8613 "file6.txt": "content",
8614 }),
8615 )
8616 .await;
8617
8618 // Test 1: Single worktree with hide_root = false
8619 {
8620 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8621 let window =
8622 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8623 let workspace = window
8624 .read_with(cx, |mw, _| mw.workspace().clone())
8625 .unwrap();
8626 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8627
8628 cx.update(|_, cx| {
8629 let settings = *ProjectPanelSettings::get_global(cx);
8630 ProjectPanelSettings::override_global(
8631 ProjectPanelSettings {
8632 hide_root: false,
8633 ..settings
8634 },
8635 cx,
8636 );
8637 });
8638
8639 let panel = workspace.update_in(cx, ProjectPanel::new);
8640 cx.run_until_parked();
8641
8642 #[rustfmt::skip]
8643 assert_eq!(
8644 visible_entries_as_strings(&panel, 0..10, cx),
8645 &[
8646 "v root1",
8647 " > dir1",
8648 " > dir2",
8649 " file4.txt",
8650 ],
8651 "With hide_root=false and single worktree, root should be visible"
8652 );
8653 }
8654
8655 // Test 2: Single worktree with hide_root = true
8656 {
8657 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8658 let window =
8659 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8660 let workspace = window
8661 .read_with(cx, |mw, _| mw.workspace().clone())
8662 .unwrap();
8663 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8664
8665 // Set hide_root to true
8666 cx.update(|_, cx| {
8667 let settings = *ProjectPanelSettings::get_global(cx);
8668 ProjectPanelSettings::override_global(
8669 ProjectPanelSettings {
8670 hide_root: true,
8671 ..settings
8672 },
8673 cx,
8674 );
8675 });
8676
8677 let panel = workspace.update_in(cx, ProjectPanel::new);
8678 cx.run_until_parked();
8679
8680 assert_eq!(
8681 visible_entries_as_strings(&panel, 0..10, cx),
8682 &["> dir1", "> dir2", " file4.txt",],
8683 "With hide_root=true and single worktree, root should be hidden"
8684 );
8685
8686 // Test expanding directories still works without root
8687 toggle_expand_dir(&panel, "root1/dir1", cx);
8688 assert_eq!(
8689 visible_entries_as_strings(&panel, 0..10, cx),
8690 &[
8691 "v dir1 <== selected",
8692 " file1.txt",
8693 " file2.txt",
8694 "> dir2",
8695 " file4.txt",
8696 ],
8697 "Should be able to expand directories even when root is hidden"
8698 );
8699 }
8700
8701 // Test 3: Multiple worktrees with hide_root = true
8702 {
8703 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8704 let window =
8705 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8706 let workspace = window
8707 .read_with(cx, |mw, _| mw.workspace().clone())
8708 .unwrap();
8709 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8710
8711 // Set hide_root to true
8712 cx.update(|_, cx| {
8713 let settings = *ProjectPanelSettings::get_global(cx);
8714 ProjectPanelSettings::override_global(
8715 ProjectPanelSettings {
8716 hide_root: true,
8717 ..settings
8718 },
8719 cx,
8720 );
8721 });
8722
8723 let panel = workspace.update_in(cx, ProjectPanel::new);
8724 cx.run_until_parked();
8725
8726 assert_eq!(
8727 visible_entries_as_strings(&panel, 0..10, cx),
8728 &[
8729 "v root1",
8730 " > dir1",
8731 " > dir2",
8732 " file4.txt",
8733 "v root2",
8734 " > dir3",
8735 " file6.txt",
8736 ],
8737 "With hide_root=true and multiple worktrees, roots should still be visible"
8738 );
8739 }
8740
8741 // Test 4: Multiple worktrees with hide_root = false
8742 {
8743 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8744 let window =
8745 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8746 let workspace = window
8747 .read_with(cx, |mw, _| mw.workspace().clone())
8748 .unwrap();
8749 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8750
8751 cx.update(|_, cx| {
8752 let settings = *ProjectPanelSettings::get_global(cx);
8753 ProjectPanelSettings::override_global(
8754 ProjectPanelSettings {
8755 hide_root: false,
8756 ..settings
8757 },
8758 cx,
8759 );
8760 });
8761
8762 let panel = workspace.update_in(cx, ProjectPanel::new);
8763 cx.run_until_parked();
8764
8765 assert_eq!(
8766 visible_entries_as_strings(&panel, 0..10, cx),
8767 &[
8768 "v root1",
8769 " > dir1",
8770 " > dir2",
8771 " file4.txt",
8772 "v root2",
8773 " > dir3",
8774 " file6.txt",
8775 ],
8776 "With hide_root=false and multiple worktrees, roots should be visible"
8777 );
8778 }
8779}
8780
8781#[gpui::test]
8782async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
8783 init_test_with_editor(cx);
8784
8785 let fs = FakeFs::new(cx.executor());
8786 fs.insert_tree(
8787 "/root",
8788 json!({
8789 "file1.txt": "content of file1",
8790 "file2.txt": "content of file2",
8791 "dir1": {
8792 "file3.txt": "content of file3"
8793 }
8794 }),
8795 )
8796 .await;
8797
8798 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8799 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8800 let workspace = window
8801 .read_with(cx, |mw, _| mw.workspace().clone())
8802 .unwrap();
8803 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8804 let panel = workspace.update_in(cx, ProjectPanel::new);
8805 cx.run_until_parked();
8806
8807 let file1_path = "root/file1.txt";
8808 let file2_path = "root/file2.txt";
8809 select_path_with_mark(&panel, file1_path, cx);
8810 select_path_with_mark(&panel, file2_path, cx);
8811
8812 panel.update_in(cx, |panel, window, cx| {
8813 panel.compare_marked_files(&CompareMarkedFiles, window, cx);
8814 });
8815 cx.executor().run_until_parked();
8816
8817 workspace.update_in(cx, |workspace, _, cx| {
8818 let active_items = workspace
8819 .panes()
8820 .iter()
8821 .filter_map(|pane| pane.read(cx).active_item())
8822 .collect::<Vec<_>>();
8823 assert_eq!(active_items.len(), 1);
8824 let diff_view = active_items
8825 .into_iter()
8826 .next()
8827 .unwrap()
8828 .downcast::<FileDiffView>()
8829 .expect("Open item should be an FileDiffView");
8830 assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
8831 assert_eq!(
8832 diff_view.tab_tooltip_text(cx).unwrap(),
8833 format!(
8834 "{} ↔ {}",
8835 rel_path(file1_path).display(PathStyle::local()),
8836 rel_path(file2_path).display(PathStyle::local())
8837 )
8838 );
8839 });
8840
8841 let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
8842 let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
8843 let worktree_id = panel.update(cx, |panel, cx| {
8844 panel
8845 .project
8846 .read(cx)
8847 .worktrees(cx)
8848 .next()
8849 .unwrap()
8850 .read(cx)
8851 .id()
8852 });
8853
8854 let expected_entries = [
8855 SelectedEntry {
8856 worktree_id,
8857 entry_id: file1_entry_id,
8858 },
8859 SelectedEntry {
8860 worktree_id,
8861 entry_id: file2_entry_id,
8862 },
8863 ];
8864 panel.update(cx, |panel, _cx| {
8865 assert_eq!(
8866 &panel.marked_entries, &expected_entries,
8867 "Should keep marked entries after comparison"
8868 );
8869 });
8870
8871 panel.update(cx, |panel, cx| {
8872 panel.project.update(cx, |_, cx| {
8873 cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
8874 })
8875 });
8876
8877 panel.update(cx, |panel, _cx| {
8878 assert_eq!(
8879 &panel.marked_entries, &expected_entries,
8880 "Marked entries should persist after focusing back on the project panel"
8881 );
8882 });
8883}
8884
8885#[gpui::test]
8886async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
8887 init_test_with_editor(cx);
8888
8889 let fs = FakeFs::new(cx.executor());
8890 fs.insert_tree(
8891 "/root",
8892 json!({
8893 "file1.txt": "content of file1",
8894 "file2.txt": "content of file2",
8895 "dir1": {},
8896 "dir2": {
8897 "file3.txt": "content of file3"
8898 }
8899 }),
8900 )
8901 .await;
8902
8903 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8904 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8905 let workspace = window
8906 .read_with(cx, |mw, _| mw.workspace().clone())
8907 .unwrap();
8908 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8909 let panel = workspace.update_in(cx, ProjectPanel::new);
8910 cx.run_until_parked();
8911
8912 // Test 1: When only one file is selected, there should be no compare option
8913 select_path(&panel, "root/file1.txt", cx);
8914
8915 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8916 assert_eq!(
8917 selected_files, None,
8918 "Should not have compare option when only one file is selected"
8919 );
8920
8921 // Test 2: When multiple files are selected, there should be a compare option
8922 select_path_with_mark(&panel, "root/file1.txt", cx);
8923 select_path_with_mark(&panel, "root/file2.txt", cx);
8924
8925 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8926 assert!(
8927 selected_files.is_some(),
8928 "Should have files selected for comparison"
8929 );
8930 if let Some((file1, file2)) = selected_files {
8931 assert!(
8932 file1.to_string_lossy().ends_with("file1.txt")
8933 && file2.to_string_lossy().ends_with("file2.txt"),
8934 "Should have file1.txt and file2.txt as the selected files when multi-selecting"
8935 );
8936 }
8937
8938 // Test 3: Selecting a directory shouldn't count as a comparable file
8939 select_path_with_mark(&panel, "root/dir1", cx);
8940
8941 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8942 assert!(
8943 selected_files.is_some(),
8944 "Directory selection should not affect comparable files"
8945 );
8946 if let Some((file1, file2)) = selected_files {
8947 assert!(
8948 file1.to_string_lossy().ends_with("file1.txt")
8949 && file2.to_string_lossy().ends_with("file2.txt"),
8950 "Selecting a directory should not affect the number of comparable files"
8951 );
8952 }
8953
8954 // Test 4: Selecting one more file
8955 select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
8956
8957 let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
8958 assert!(
8959 selected_files.is_some(),
8960 "Directory selection should not affect comparable files"
8961 );
8962 if let Some((file1, file2)) = selected_files {
8963 assert!(
8964 file1.to_string_lossy().ends_with("file2.txt")
8965 && file2.to_string_lossy().ends_with("file3.txt"),
8966 "Selecting a directory should not affect the number of comparable files"
8967 );
8968 }
8969}
8970
8971#[gpui::test]
8972async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root(
8973 cx: &mut gpui::TestAppContext,
8974) {
8975 init_test(cx);
8976
8977 let fs = FakeFs::new(cx.executor());
8978 fs.insert_tree(
8979 "/root",
8980 json!({
8981 "file.txt": "content",
8982 "dir": {},
8983 }),
8984 )
8985 .await;
8986
8987 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8988 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
8989 let workspace = window
8990 .read_with(cx, |mw, _| mw.workspace().clone())
8991 .unwrap();
8992 let cx = &mut VisualTestContext::from_window(window.into(), cx);
8993 let panel = workspace.update_in(cx, ProjectPanel::new);
8994 cx.run_until_parked();
8995
8996 select_path(&panel, "root/file.txt", cx);
8997 let selected_reveal_path = panel
8998 .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
8999 .expect("selected entry should produce a reveal path");
9000 assert!(
9001 selected_reveal_path.ends_with(Path::new("file.txt")),
9002 "Expected selected file path, got {:?}",
9003 selected_reveal_path
9004 );
9005
9006 panel.update(cx, |panel, _| {
9007 panel.selection = None;
9008 panel.marked_entries.clear();
9009 });
9010 let fallback_reveal_path = panel
9011 .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx))
9012 .expect("project root should be used when selection is empty");
9013 assert!(
9014 fallback_reveal_path.ends_with(Path::new("root")),
9015 "Expected worktree root path, got {:?}",
9016 fallback_reveal_path
9017 );
9018}
9019
9020#[gpui::test]
9021async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) {
9022 init_test(cx);
9023
9024 let fs = FakeFs::new(cx.executor());
9025 fs.insert_tree(
9026 "/root",
9027 json!({
9028 ".hidden-file.txt": "hidden file content",
9029 "visible-file.txt": "visible file content",
9030 ".hidden-parent-dir": {
9031 "nested-dir": {
9032 "file.txt": "file content",
9033 }
9034 },
9035 "visible-dir": {
9036 "file-in-visible.txt": "file content",
9037 "nested": {
9038 ".hidden-nested-dir": {
9039 ".double-hidden-dir": {
9040 "deep-file-1.txt": "deep content 1",
9041 "deep-file-2.txt": "deep content 2"
9042 },
9043 "hidden-nested-file-1.txt": "hidden nested 1",
9044 "hidden-nested-file-2.txt": "hidden nested 2"
9045 },
9046 "visible-nested-file.txt": "visible nested content"
9047 }
9048 }
9049 }),
9050 )
9051 .await;
9052
9053 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9054 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9055 let workspace = window
9056 .read_with(cx, |mw, _| mw.workspace().clone())
9057 .unwrap();
9058 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9059
9060 cx.update(|_, cx| {
9061 let settings = *ProjectPanelSettings::get_global(cx);
9062 ProjectPanelSettings::override_global(
9063 ProjectPanelSettings {
9064 hide_hidden: false,
9065 ..settings
9066 },
9067 cx,
9068 );
9069 });
9070
9071 let panel = workspace.update_in(cx, ProjectPanel::new);
9072 cx.run_until_parked();
9073
9074 toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx);
9075 toggle_expand_dir(&panel, "root/.hidden-parent-dir/nested-dir", cx);
9076 toggle_expand_dir(&panel, "root/visible-dir", cx);
9077 toggle_expand_dir(&panel, "root/visible-dir/nested", cx);
9078 toggle_expand_dir(&panel, "root/visible-dir/nested/.hidden-nested-dir", cx);
9079 toggle_expand_dir(
9080 &panel,
9081 "root/visible-dir/nested/.hidden-nested-dir/.double-hidden-dir",
9082 cx,
9083 );
9084
9085 let expanded = [
9086 "v root",
9087 " v .hidden-parent-dir",
9088 " v nested-dir",
9089 " file.txt",
9090 " v visible-dir",
9091 " v nested",
9092 " v .hidden-nested-dir",
9093 " v .double-hidden-dir <== selected",
9094 " deep-file-1.txt",
9095 " deep-file-2.txt",
9096 " hidden-nested-file-1.txt",
9097 " hidden-nested-file-2.txt",
9098 " visible-nested-file.txt",
9099 " file-in-visible.txt",
9100 " .hidden-file.txt",
9101 " visible-file.txt",
9102 ];
9103
9104 assert_eq!(
9105 visible_entries_as_strings(&panel, 0..30, cx),
9106 &expanded,
9107 "With hide_hidden=false, contents of hidden nested directory should be visible"
9108 );
9109
9110 cx.update(|_, cx| {
9111 let settings = *ProjectPanelSettings::get_global(cx);
9112 ProjectPanelSettings::override_global(
9113 ProjectPanelSettings {
9114 hide_hidden: true,
9115 ..settings
9116 },
9117 cx,
9118 );
9119 });
9120
9121 panel.update_in(cx, |panel, window, cx| {
9122 panel.update_visible_entries(None, false, false, window, cx);
9123 });
9124 cx.run_until_parked();
9125
9126 assert_eq!(
9127 visible_entries_as_strings(&panel, 0..30, cx),
9128 &[
9129 "v root",
9130 " v visible-dir",
9131 " v nested",
9132 " visible-nested-file.txt",
9133 " file-in-visible.txt",
9134 " visible-file.txt",
9135 ],
9136 "With hide_hidden=false, contents of hidden nested directory should be visible"
9137 );
9138
9139 panel.update_in(cx, |panel, window, cx| {
9140 let settings = *ProjectPanelSettings::get_global(cx);
9141 ProjectPanelSettings::override_global(
9142 ProjectPanelSettings {
9143 hide_hidden: false,
9144 ..settings
9145 },
9146 cx,
9147 );
9148 panel.update_visible_entries(None, false, false, window, cx);
9149 });
9150 cx.run_until_parked();
9151
9152 assert_eq!(
9153 visible_entries_as_strings(&panel, 0..30, cx),
9154 &expanded,
9155 "With hide_hidden=false, deeply nested hidden directories and their contents should be visible"
9156 );
9157}
9158
9159pub(crate) fn select_path(panel: &Entity<ProjectPanel>, path: &str, cx: &mut VisualTestContext) {
9160 let path = rel_path(path);
9161 panel.update_in(cx, |panel, window, cx| {
9162 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9163 let worktree = worktree.read(cx);
9164 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9165 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9166 panel.update_visible_entries(
9167 Some((worktree.id(), entry_id)),
9168 false,
9169 false,
9170 window,
9171 cx,
9172 );
9173 return;
9174 }
9175 }
9176 panic!("no worktree for path {:?}", path);
9177 });
9178 cx.run_until_parked();
9179}
9180
9181pub(crate) fn select_path_with_mark(
9182 panel: &Entity<ProjectPanel>,
9183 path: &str,
9184 cx: &mut VisualTestContext,
9185) {
9186 let path = rel_path(path);
9187 panel.update(cx, |panel, cx| {
9188 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9189 let worktree = worktree.read(cx);
9190 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9191 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9192 let entry = crate::SelectedEntry {
9193 worktree_id: worktree.id(),
9194 entry_id,
9195 };
9196 if !panel.marked_entries.contains(&entry) {
9197 panel.marked_entries.push(entry);
9198 }
9199 panel.selection = Some(entry);
9200 return;
9201 }
9202 }
9203 panic!("no worktree for path {:?}", path);
9204 });
9205}
9206
9207/// `leaf_path` is the full path to the leaf entry (e.g., "root/a/b/c")
9208/// `active_ancestor_path` is the path to the folded component that should be active.
9209fn select_folded_path_with_mark(
9210 panel: &Entity<ProjectPanel>,
9211 leaf_path: &str,
9212 active_ancestor_path: &str,
9213 cx: &mut VisualTestContext,
9214) {
9215 select_path_with_mark(panel, leaf_path, cx);
9216 set_folded_active_ancestor(panel, leaf_path, active_ancestor_path, cx);
9217}
9218
9219fn set_folded_active_ancestor(
9220 panel: &Entity<ProjectPanel>,
9221 leaf_path: &str,
9222 active_ancestor_path: &str,
9223 cx: &mut VisualTestContext,
9224) {
9225 let leaf_path = rel_path(leaf_path);
9226 let active_ancestor_path = rel_path(active_ancestor_path);
9227 panel.update(cx, |panel, cx| {
9228 let mut leaf_entry_id = None;
9229 let mut target_entry_id = None;
9230
9231 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9232 let worktree = worktree.read(cx);
9233 if let Ok(relative_path) = leaf_path.strip_prefix(worktree.root_name()) {
9234 leaf_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
9235 }
9236 if let Ok(relative_path) = active_ancestor_path.strip_prefix(worktree.root_name()) {
9237 target_entry_id = worktree.entry_for_path(relative_path).map(|entry| entry.id);
9238 }
9239 }
9240
9241 let leaf_entry_id =
9242 leaf_entry_id.unwrap_or_else(|| panic!("no entry for leaf path {leaf_path:?}"));
9243 let target_entry_id = target_entry_id
9244 .unwrap_or_else(|| panic!("no entry for active path {active_ancestor_path:?}"));
9245 let folded_ancestors = panel
9246 .state
9247 .ancestors
9248 .get_mut(&leaf_entry_id)
9249 .unwrap_or_else(|| panic!("leaf path {leaf_path:?} should be folded"));
9250 let ancestor_ids = folded_ancestors.ancestors.clone();
9251
9252 let mut depth_for_target = None;
9253 for depth in 0..ancestor_ids.len() {
9254 let resolved_entry_id = if depth == 0 {
9255 leaf_entry_id
9256 } else {
9257 ancestor_ids.get(depth).copied().unwrap_or(leaf_entry_id)
9258 };
9259 if resolved_entry_id == target_entry_id {
9260 depth_for_target = Some(depth);
9261 break;
9262 }
9263 }
9264
9265 folded_ancestors.current_ancestor_depth = depth_for_target.unwrap_or_else(|| {
9266 panic!(
9267 "active path {active_ancestor_path:?} is not part of folded ancestors {ancestor_ids:?}"
9268 )
9269 });
9270 });
9271}
9272
9273pub(crate) fn drag_selection_to(
9274 panel: &Entity<ProjectPanel>,
9275 target_path: &str,
9276 is_file: bool,
9277 cx: &mut VisualTestContext,
9278) {
9279 let target_entry = find_project_entry(panel, target_path, cx)
9280 .unwrap_or_else(|| panic!("no entry for target path {target_path:?}"));
9281
9282 panel.update_in(cx, |panel, window, cx| {
9283 let selection = panel
9284 .selection
9285 .expect("a selection is required before dragging");
9286 let drag = DraggedSelection {
9287 active_selection: SelectedEntry {
9288 worktree_id: selection.worktree_id,
9289 entry_id: panel.resolve_entry(selection.entry_id),
9290 },
9291 marked_selections: Arc::from(panel.marked_entries.clone()),
9292 };
9293 panel.drag_onto(&drag, target_entry, is_file, window, cx);
9294 });
9295 cx.executor().run_until_parked();
9296}
9297
9298pub(crate) fn find_project_entry(
9299 panel: &Entity<ProjectPanel>,
9300 path: &str,
9301 cx: &mut VisualTestContext,
9302) -> Option<ProjectEntryId> {
9303 let path = rel_path(path);
9304 panel.update(cx, |panel, cx| {
9305 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9306 let worktree = worktree.read(cx);
9307 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9308 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9309 }
9310 }
9311 panic!("no worktree for path {path:?}");
9312 })
9313}
9314
9315fn visible_entries_as_strings(
9316 panel: &Entity<ProjectPanel>,
9317 range: Range<usize>,
9318 cx: &mut VisualTestContext,
9319) -> Vec<String> {
9320 let mut result = Vec::new();
9321 let mut project_entries = HashSet::default();
9322 let mut has_editor = false;
9323
9324 panel.update_in(cx, |panel, window, cx| {
9325 panel.for_each_visible_entry(range, window, cx, &mut |project_entry, details, _, _| {
9326 if details.is_editing {
9327 assert!(!has_editor, "duplicate editor entry");
9328 has_editor = true;
9329 } else {
9330 assert!(
9331 project_entries.insert(project_entry),
9332 "duplicate project entry {:?} {:?}",
9333 project_entry,
9334 details
9335 );
9336 }
9337
9338 let indent = " ".repeat(details.depth);
9339 let icon = if details.kind.is_dir() {
9340 if details.is_expanded { "v " } else { "> " }
9341 } else {
9342 " "
9343 };
9344 #[cfg(windows)]
9345 let filename = details.filename.replace("\\", "/");
9346 #[cfg(not(windows))]
9347 let filename = details.filename;
9348 let name = if details.is_editing {
9349 format!("[EDITOR: '{}']", filename)
9350 } else if details.is_processing {
9351 format!("[PROCESSING: '{}']", filename)
9352 } else {
9353 filename
9354 };
9355 let selected = if details.is_selected {
9356 " <== selected"
9357 } else {
9358 ""
9359 };
9360 let marked = if details.is_marked {
9361 " <== marked"
9362 } else {
9363 ""
9364 };
9365
9366 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9367 });
9368 });
9369
9370 result
9371}
9372
9373/// Test that missing sort_mode field defaults to DirectoriesFirst
9374#[gpui::test]
9375async fn test_sort_mode_default_fallback(cx: &mut gpui::TestAppContext) {
9376 init_test(cx);
9377
9378 // Verify that when sort_mode is not specified, it defaults to DirectoriesFirst
9379 let default_settings = cx.read(|cx| *ProjectPanelSettings::get_global(cx));
9380 assert_eq!(
9381 default_settings.sort_mode,
9382 settings::ProjectPanelSortMode::DirectoriesFirst,
9383 "sort_mode should default to DirectoriesFirst"
9384 );
9385}
9386
9387/// Test sort modes: DirectoriesFirst (default) vs Mixed
9388#[gpui::test]
9389async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) {
9390 init_test(cx);
9391
9392 let fs = FakeFs::new(cx.executor());
9393 fs.insert_tree(
9394 "/root",
9395 json!({
9396 "zebra.txt": "",
9397 "Apple": {},
9398 "banana.rs": "",
9399 "Carrot": {},
9400 "aardvark.txt": "",
9401 }),
9402 )
9403 .await;
9404
9405 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9406 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9407 let workspace = window
9408 .read_with(cx, |mw, _| mw.workspace().clone())
9409 .unwrap();
9410 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9411 let panel = workspace.update_in(cx, ProjectPanel::new);
9412 cx.run_until_parked();
9413
9414 // Default sort mode should be DirectoriesFirst
9415 assert_eq!(
9416 visible_entries_as_strings(&panel, 0..50, cx),
9417 &[
9418 "v root",
9419 " > Apple",
9420 " > Carrot",
9421 " aardvark.txt",
9422 " banana.rs",
9423 " zebra.txt",
9424 ]
9425 );
9426}
9427
9428#[gpui::test]
9429async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) {
9430 init_test(cx);
9431
9432 let fs = FakeFs::new(cx.executor());
9433 fs.insert_tree(
9434 "/root",
9435 json!({
9436 "Zebra.txt": "",
9437 "apple": {},
9438 "Banana.rs": "",
9439 "carrot": {},
9440 "Aardvark.txt": "",
9441 }),
9442 )
9443 .await;
9444
9445 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9446 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9447 let workspace = window
9448 .read_with(cx, |mw, _| mw.workspace().clone())
9449 .unwrap();
9450 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9451
9452 // Switch to Mixed mode
9453 cx.update(|_, cx| {
9454 cx.update_global::<SettingsStore, _>(|store, cx| {
9455 store.update_user_settings(cx, |settings| {
9456 settings.project_panel.get_or_insert_default().sort_mode =
9457 Some(settings::ProjectPanelSortMode::Mixed);
9458 });
9459 });
9460 });
9461
9462 let panel = workspace.update_in(cx, ProjectPanel::new);
9463 cx.run_until_parked();
9464
9465 // Mixed mode: case-insensitive sorting
9466 // Aardvark < apple < Banana < carrot < Zebra (all case-insensitive)
9467 assert_eq!(
9468 visible_entries_as_strings(&panel, 0..50, cx),
9469 &[
9470 "v root",
9471 " Aardvark.txt",
9472 " > apple",
9473 " Banana.rs",
9474 " > carrot",
9475 " Zebra.txt",
9476 ]
9477 );
9478}
9479
9480#[gpui::test]
9481async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) {
9482 init_test(cx);
9483
9484 let fs = FakeFs::new(cx.executor());
9485 fs.insert_tree(
9486 "/root",
9487 json!({
9488 "Zebra.txt": "",
9489 "apple": {},
9490 "Banana.rs": "",
9491 "carrot": {},
9492 "Aardvark.txt": "",
9493 }),
9494 )
9495 .await;
9496
9497 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9498 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9499 let workspace = window
9500 .read_with(cx, |mw, _| mw.workspace().clone())
9501 .unwrap();
9502 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9503
9504 // Switch to FilesFirst mode
9505 cx.update(|_, cx| {
9506 cx.update_global::<SettingsStore, _>(|store, cx| {
9507 store.update_user_settings(cx, |settings| {
9508 settings.project_panel.get_or_insert_default().sort_mode =
9509 Some(settings::ProjectPanelSortMode::FilesFirst);
9510 });
9511 });
9512 });
9513
9514 let panel = workspace.update_in(cx, ProjectPanel::new);
9515 cx.run_until_parked();
9516
9517 // FilesFirst mode: files first, then directories (both case-insensitive)
9518 assert_eq!(
9519 visible_entries_as_strings(&panel, 0..50, cx),
9520 &[
9521 "v root",
9522 " Aardvark.txt",
9523 " Banana.rs",
9524 " Zebra.txt",
9525 " > apple",
9526 " > carrot",
9527 ]
9528 );
9529}
9530
9531#[gpui::test]
9532async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) {
9533 init_test(cx);
9534
9535 let fs = FakeFs::new(cx.executor());
9536 fs.insert_tree(
9537 "/root",
9538 json!({
9539 "file2.txt": "",
9540 "dir1": {},
9541 "file1.txt": "",
9542 }),
9543 )
9544 .await;
9545
9546 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9547 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9548 let workspace = window
9549 .read_with(cx, |mw, _| mw.workspace().clone())
9550 .unwrap();
9551 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9552 let panel = workspace.update_in(cx, ProjectPanel::new);
9553 cx.run_until_parked();
9554
9555 // Initially DirectoriesFirst
9556 assert_eq!(
9557 visible_entries_as_strings(&panel, 0..50, cx),
9558 &["v root", " > dir1", " file1.txt", " file2.txt",]
9559 );
9560
9561 // Toggle to Mixed
9562 cx.update(|_, cx| {
9563 cx.update_global::<SettingsStore, _>(|store, cx| {
9564 store.update_user_settings(cx, |settings| {
9565 settings.project_panel.get_or_insert_default().sort_mode =
9566 Some(settings::ProjectPanelSortMode::Mixed);
9567 });
9568 });
9569 });
9570 cx.run_until_parked();
9571
9572 assert_eq!(
9573 visible_entries_as_strings(&panel, 0..50, cx),
9574 &["v root", " > dir1", " file1.txt", " file2.txt",]
9575 );
9576
9577 // Toggle back to DirectoriesFirst
9578 cx.update(|_, cx| {
9579 cx.update_global::<SettingsStore, _>(|store, cx| {
9580 store.update_user_settings(cx, |settings| {
9581 settings.project_panel.get_or_insert_default().sort_mode =
9582 Some(settings::ProjectPanelSortMode::DirectoriesFirst);
9583 });
9584 });
9585 });
9586 cx.run_until_parked();
9587
9588 assert_eq!(
9589 visible_entries_as_strings(&panel, 0..50, cx),
9590 &["v root", " > dir1", " file1.txt", " file2.txt",]
9591 );
9592}
9593
9594#[gpui::test]
9595async fn test_ensure_temporary_folding_when_creating_in_different_nested_dirs(
9596 cx: &mut gpui::TestAppContext,
9597) {
9598 init_test(cx);
9599
9600 // parent: accept
9601 run_create_file_in_folded_path_case(
9602 "parent",
9603 "root1/parent",
9604 "file_in_parent.txt",
9605 &[
9606 "v root1",
9607 " v parent",
9608 " > subdir/child",
9609 " [EDITOR: ''] <== selected",
9610 ],
9611 &[
9612 "v root1",
9613 " v parent",
9614 " > subdir/child",
9615 " file_in_parent.txt <== selected <== marked",
9616 ],
9617 true,
9618 cx,
9619 )
9620 .await;
9621
9622 // parent: cancel
9623 run_create_file_in_folded_path_case(
9624 "parent",
9625 "root1/parent",
9626 "file_in_parent.txt",
9627 &[
9628 "v root1",
9629 " v parent",
9630 " > subdir/child",
9631 " [EDITOR: ''] <== selected",
9632 ],
9633 &["v root1", " > parent/subdir/child <== selected"],
9634 false,
9635 cx,
9636 )
9637 .await;
9638
9639 // subdir: accept
9640 run_create_file_in_folded_path_case(
9641 "subdir",
9642 "root1/parent/subdir",
9643 "file_in_subdir.txt",
9644 &[
9645 "v root1",
9646 " v parent/subdir",
9647 " > child",
9648 " [EDITOR: ''] <== selected",
9649 ],
9650 &[
9651 "v root1",
9652 " v parent/subdir",
9653 " > child",
9654 " file_in_subdir.txt <== selected <== marked",
9655 ],
9656 true,
9657 cx,
9658 )
9659 .await;
9660
9661 // subdir: cancel
9662 run_create_file_in_folded_path_case(
9663 "subdir",
9664 "root1/parent/subdir",
9665 "file_in_subdir.txt",
9666 &[
9667 "v root1",
9668 " v parent/subdir",
9669 " > child",
9670 " [EDITOR: ''] <== selected",
9671 ],
9672 &["v root1", " > parent/subdir/child <== selected"],
9673 false,
9674 cx,
9675 )
9676 .await;
9677
9678 // child: accept
9679 run_create_file_in_folded_path_case(
9680 "child",
9681 "root1/parent/subdir/child",
9682 "file_in_child.txt",
9683 &[
9684 "v root1",
9685 " v parent/subdir/child",
9686 " [EDITOR: ''] <== selected",
9687 ],
9688 &[
9689 "v root1",
9690 " v parent/subdir/child",
9691 " file_in_child.txt <== selected <== marked",
9692 ],
9693 true,
9694 cx,
9695 )
9696 .await;
9697
9698 // child: cancel
9699 run_create_file_in_folded_path_case(
9700 "child",
9701 "root1/parent/subdir/child",
9702 "file_in_child.txt",
9703 &[
9704 "v root1",
9705 " v parent/subdir/child",
9706 " [EDITOR: ''] <== selected",
9707 ],
9708 &["v root1", " v parent/subdir/child <== selected"],
9709 false,
9710 cx,
9711 )
9712 .await;
9713}
9714
9715#[gpui::test]
9716async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu(
9717 cx: &mut gpui::TestAppContext,
9718) {
9719 init_test(cx);
9720
9721 let fs = FakeFs::new(cx.executor());
9722 fs.insert_tree(
9723 "/root1",
9724 json!({
9725 "parent": {
9726 "subdir": {
9727 "child": {},
9728 }
9729 }
9730 }),
9731 )
9732 .await;
9733
9734 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9735 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9736 let workspace = window
9737 .read_with(cx, |mw, _| mw.workspace().clone())
9738 .unwrap();
9739 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9740
9741 let panel = workspace.update_in(cx, |workspace, window, cx| {
9742 let panel = ProjectPanel::new(workspace, window, cx);
9743 workspace.add_panel(panel.clone(), window, cx);
9744 panel
9745 });
9746
9747 cx.update(|_, cx| {
9748 let settings = *ProjectPanelSettings::get_global(cx);
9749 ProjectPanelSettings::override_global(
9750 ProjectPanelSettings {
9751 auto_fold_dirs: true,
9752 ..settings
9753 },
9754 cx,
9755 );
9756 });
9757
9758 panel.update_in(cx, |panel, window, cx| {
9759 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9760 });
9761 cx.run_until_parked();
9762
9763 select_folded_path_with_mark(
9764 &panel,
9765 "root1/parent/subdir/child",
9766 "root1/parent/subdir",
9767 cx,
9768 );
9769 panel.update(cx, |panel, _| {
9770 panel.marked_entries.clear();
9771 });
9772
9773 let parent_entry_id = find_project_entry(&panel, "root1/parent", cx)
9774 .expect("parent directory should exist for this test");
9775 let subdir_entry_id = find_project_entry(&panel, "root1/parent/subdir", cx)
9776 .expect("subdir directory should exist for this test");
9777 let child_entry_id = find_project_entry(&panel, "root1/parent/subdir/child", cx)
9778 .expect("child directory should exist for this test");
9779
9780 panel.update(cx, |panel, _| {
9781 let selection = panel
9782 .selection
9783 .expect("leaf directory should be selected before creating a new entry");
9784 assert_eq!(
9785 selection.entry_id, child_entry_id,
9786 "initial selection should be the folded leaf entry"
9787 );
9788 assert_eq!(
9789 panel.resolve_entry(selection.entry_id),
9790 subdir_entry_id,
9791 "active folded component should start at subdir"
9792 );
9793 });
9794
9795 panel.update_in(cx, |panel, window, cx| {
9796 panel.deploy_context_menu(
9797 gpui::point(gpui::px(1.), gpui::px(1.)),
9798 child_entry_id,
9799 window,
9800 cx,
9801 );
9802 panel.new_file(&NewFile, window, cx);
9803 });
9804 cx.run_until_parked();
9805 panel.update_in(cx, |panel, window, cx| {
9806 assert!(panel.filename_editor.read(cx).is_focused(window));
9807 });
9808 cx.run_until_parked();
9809
9810 set_folded_active_ancestor(&panel, "root1/parent/subdir", "root1/parent", cx);
9811
9812 panel.update_in(cx, |panel, window, cx| {
9813 panel.deploy_context_menu(
9814 gpui::point(gpui::px(2.), gpui::px(2.)),
9815 subdir_entry_id,
9816 window,
9817 cx,
9818 );
9819 });
9820 cx.run_until_parked();
9821
9822 panel.update(cx, |panel, _| {
9823 assert!(
9824 panel.state.edit_state.is_none(),
9825 "opening another context menu should blur the filename editor and discard edit state"
9826 );
9827 let selection = panel
9828 .selection
9829 .expect("selection should restore to the previously focused leaf entry");
9830 assert_eq!(
9831 selection.entry_id, child_entry_id,
9832 "blur-driven cancellation should restore the previous leaf selection"
9833 );
9834 assert_eq!(
9835 panel.resolve_entry(selection.entry_id),
9836 parent_entry_id,
9837 "temporary unfolded pending state should preserve the active ancestor chosen before blur"
9838 );
9839 });
9840
9841 panel.update_in(cx, |panel, window, cx| {
9842 panel.new_file(&NewFile, window, cx);
9843 });
9844 cx.run_until_parked();
9845 assert_eq!(
9846 visible_entries_as_strings(&panel, 0..10, cx),
9847 &[
9848 "v root1",
9849 " v parent",
9850 " > subdir/child",
9851 " [EDITOR: ''] <== selected",
9852 ],
9853 "new file after blur should use the preserved active ancestor"
9854 );
9855 panel.update(cx, |panel, _| {
9856 let edit_state = panel
9857 .state
9858 .edit_state
9859 .as_ref()
9860 .expect("new file should enter edit state");
9861 assert_eq!(
9862 edit_state.temporarily_unfolded,
9863 Some(parent_entry_id),
9864 "temporary unfolding should now target parent after restoring the active ancestor"
9865 );
9866 });
9867
9868 let file_name = "created_after_blur.txt";
9869 panel
9870 .update_in(cx, |panel, window, cx| {
9871 panel.filename_editor.update(cx, |editor, cx| {
9872 editor.set_text(file_name, window, cx);
9873 });
9874 panel.confirm_edit(true, window, cx).expect(
9875 "confirm_edit should start creation for the file created after blur transition",
9876 )
9877 })
9878 .await
9879 .expect("creating file after blur transition should succeed");
9880 cx.run_until_parked();
9881
9882 assert!(
9883 fs.is_file(Path::new("/root1/parent/created_after_blur.txt"))
9884 .await,
9885 "file should be created under parent after active ancestor is restored to parent"
9886 );
9887 assert!(
9888 !fs.is_file(Path::new("/root1/parent/subdir/created_after_blur.txt"))
9889 .await,
9890 "file should not be created under subdir when parent is the active ancestor"
9891 );
9892}
9893
9894async fn run_create_file_in_folded_path_case(
9895 case_name: &str,
9896 active_ancestor_path: &str,
9897 created_file_name: &str,
9898 expected_temporary_state: &[&str],
9899 expected_final_state: &[&str],
9900 accept_creation: bool,
9901 cx: &mut gpui::TestAppContext,
9902) {
9903 let expected_collapsed_state = &["v root1", " > parent/subdir/child <== selected"];
9904
9905 let fs = FakeFs::new(cx.executor());
9906 fs.insert_tree(
9907 "/root1",
9908 json!({
9909 "parent": {
9910 "subdir": {
9911 "child": {},
9912 }
9913 }
9914 }),
9915 )
9916 .await;
9917
9918 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
9919 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9920 let workspace = window
9921 .read_with(cx, |mw, _| mw.workspace().clone())
9922 .unwrap();
9923 let cx = &mut VisualTestContext::from_window(window.into(), cx);
9924
9925 let panel = workspace.update_in(cx, |workspace, window, cx| {
9926 let panel = ProjectPanel::new(workspace, window, cx);
9927 workspace.add_panel(panel.clone(), window, cx);
9928 panel
9929 });
9930
9931 cx.update(|_, cx| {
9932 let settings = *ProjectPanelSettings::get_global(cx);
9933 ProjectPanelSettings::override_global(
9934 ProjectPanelSettings {
9935 auto_fold_dirs: true,
9936 ..settings
9937 },
9938 cx,
9939 );
9940 });
9941
9942 panel.update_in(cx, |panel, window, cx| {
9943 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9944 });
9945 cx.run_until_parked();
9946
9947 select_folded_path_with_mark(
9948 &panel,
9949 "root1/parent/subdir/child",
9950 active_ancestor_path,
9951 cx,
9952 );
9953 panel.update(cx, |panel, _| {
9954 panel.marked_entries.clear();
9955 });
9956
9957 assert_eq!(
9958 visible_entries_as_strings(&panel, 0..10, cx),
9959 expected_collapsed_state,
9960 "case '{}' should start from a folded state",
9961 case_name
9962 );
9963
9964 panel.update_in(cx, |panel, window, cx| {
9965 panel.new_file(&NewFile, window, cx);
9966 });
9967 cx.run_until_parked();
9968 panel.update_in(cx, |panel, window, cx| {
9969 assert!(panel.filename_editor.read(cx).is_focused(window));
9970 });
9971 cx.run_until_parked();
9972 assert_eq!(
9973 visible_entries_as_strings(&panel, 0..10, cx),
9974 expected_temporary_state,
9975 "case '{}' ({}) should temporarily unfold the active ancestor while editing",
9976 case_name,
9977 if accept_creation { "accept" } else { "cancel" }
9978 );
9979
9980 let relative_directory = active_ancestor_path
9981 .strip_prefix("root1/")
9982 .expect("active_ancestor_path should start with root1/");
9983 let created_file_path = PathBuf::from("/root1")
9984 .join(relative_directory)
9985 .join(created_file_name);
9986
9987 if accept_creation {
9988 panel
9989 .update_in(cx, |panel, window, cx| {
9990 panel.filename_editor.update(cx, |editor, cx| {
9991 editor.set_text(created_file_name, window, cx);
9992 });
9993 panel.confirm_edit(true, window, cx).unwrap()
9994 })
9995 .await
9996 .unwrap();
9997 cx.run_until_parked();
9998
9999 assert_eq!(
10000 visible_entries_as_strings(&panel, 0..10, cx),
10001 expected_final_state,
10002 "case '{}' should keep the newly created file selected and marked after accept",
10003 case_name
10004 );
10005 assert!(
10006 fs.is_file(created_file_path.as_path()).await,
10007 "case '{}' should create file '{}'",
10008 case_name,
10009 created_file_path.display()
10010 );
10011 } else {
10012 panel.update_in(cx, |panel, window, cx| {
10013 panel.cancel(&Cancel, window, cx);
10014 });
10015 cx.run_until_parked();
10016
10017 assert_eq!(
10018 visible_entries_as_strings(&panel, 0..10, cx),
10019 expected_final_state,
10020 "case '{}' should keep the expected panel state after cancel",
10021 case_name
10022 );
10023 assert!(
10024 !fs.is_file(created_file_path.as_path()).await,
10025 "case '{}' should not create a file after cancel",
10026 case_name
10027 );
10028 }
10029}
10030
10031pub(crate) fn init_test(cx: &mut TestAppContext) {
10032 cx.update(|cx| {
10033 let settings_store = SettingsStore::test(cx);
10034 cx.set_global(settings_store);
10035 theme_settings::init(theme::LoadThemes::JustBase, cx);
10036 crate::init(cx);
10037
10038 cx.update_global::<SettingsStore, _>(|store, cx| {
10039 store.update_user_settings(cx, |settings| {
10040 settings
10041 .project_panel
10042 .get_or_insert_default()
10043 .auto_fold_dirs = Some(false);
10044 settings.project.worktree.file_scan_exclusions = Some(Vec::new());
10045 });
10046 });
10047 });
10048}
10049
10050fn init_test_with_editor(cx: &mut TestAppContext) {
10051 cx.update(|cx| {
10052 let app_state = AppState::test(cx);
10053 theme_settings::init(theme::LoadThemes::JustBase, cx);
10054 editor::init(cx);
10055 crate::init(cx);
10056 workspace::init(app_state, cx);
10057
10058 cx.update_global::<SettingsStore, _>(|store, cx| {
10059 store.update_user_settings(cx, |settings| {
10060 settings
10061 .project_panel
10062 .get_or_insert_default()
10063 .auto_fold_dirs = Some(false);
10064 settings.project.worktree.file_scan_exclusions = Some(Vec::new())
10065 });
10066 });
10067 });
10068}
10069
10070fn set_auto_open_settings(
10071 cx: &mut TestAppContext,
10072 auto_open_settings: ProjectPanelAutoOpenSettings,
10073) {
10074 cx.update(|cx| {
10075 cx.update_global::<SettingsStore, _>(|store, cx| {
10076 store.update_user_settings(cx, |settings| {
10077 settings.project_panel.get_or_insert_default().auto_open = Some(auto_open_settings);
10078 });
10079 })
10080 });
10081}
10082
10083fn ensure_single_file_is_opened(
10084 workspace: &Entity<Workspace>,
10085 expected_path: &str,
10086 cx: &mut VisualTestContext,
10087) {
10088 workspace.update_in(cx, |workspace, _, cx| {
10089 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
10090 assert_eq!(worktrees.len(), 1);
10091 let worktree_id = worktrees[0].read(cx).id();
10092
10093 let open_project_paths = workspace
10094 .panes()
10095 .iter()
10096 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
10097 .collect::<Vec<_>>();
10098 assert_eq!(
10099 open_project_paths,
10100 vec![ProjectPath {
10101 worktree_id,
10102 path: Arc::from(rel_path(expected_path))
10103 }],
10104 "Should have opened file, selected in project panel"
10105 );
10106 });
10107}
10108
10109fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
10110 assert!(
10111 !cx.has_pending_prompt(),
10112 "Should have no prompts before the deletion"
10113 );
10114 panel.update_in(cx, |panel, window, cx| {
10115 panel.delete(&Delete { skip_prompt: false }, window, cx)
10116 });
10117 assert!(
10118 cx.has_pending_prompt(),
10119 "Should have a prompt after the deletion"
10120 );
10121 cx.simulate_prompt_answer("Delete");
10122 assert!(
10123 !cx.has_pending_prompt(),
10124 "Should have no prompts after prompt was replied to"
10125 );
10126 cx.executor().run_until_parked();
10127}
10128
10129fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
10130 assert!(
10131 !cx.has_pending_prompt(),
10132 "Should have no prompts before the deletion"
10133 );
10134 panel.update_in(cx, |panel, window, cx| {
10135 panel.delete(&Delete { skip_prompt: true }, window, cx)
10136 });
10137 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
10138 cx.executor().run_until_parked();
10139}
10140
10141fn ensure_no_open_items_and_panes(workspace: &Entity<Workspace>, cx: &mut VisualTestContext) {
10142 assert!(
10143 !cx.has_pending_prompt(),
10144 "Should have no prompts after deletion operation closes the file"
10145 );
10146 workspace.update_in(cx, |workspace, _window, cx| {
10147 let open_project_paths = workspace
10148 .panes()
10149 .iter()
10150 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
10151 .collect::<Vec<_>>();
10152 assert!(
10153 open_project_paths.is_empty(),
10154 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
10155 );
10156 });
10157}
10158
10159struct TestProjectItemView {
10160 focus_handle: FocusHandle,
10161 path: ProjectPath,
10162}
10163
10164struct TestProjectItem {
10165 path: ProjectPath,
10166}
10167
10168impl project::ProjectItem for TestProjectItem {
10169 fn try_open(
10170 _project: &Entity<Project>,
10171 path: &ProjectPath,
10172 cx: &mut App,
10173 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
10174 let path = path.clone();
10175 Some(cx.spawn(async move |cx| Ok(cx.new(|_| Self { path }))))
10176 }
10177
10178 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
10179 None
10180 }
10181
10182 fn project_path(&self, _: &App) -> Option<ProjectPath> {
10183 Some(self.path.clone())
10184 }
10185
10186 fn is_dirty(&self) -> bool {
10187 false
10188 }
10189}
10190
10191impl ProjectItem for TestProjectItemView {
10192 type Item = TestProjectItem;
10193
10194 fn for_project_item(
10195 _: Entity<Project>,
10196 _: Option<&Pane>,
10197 project_item: Entity<Self::Item>,
10198 _: &mut Window,
10199 cx: &mut Context<Self>,
10200 ) -> Self
10201 where
10202 Self: Sized,
10203 {
10204 Self {
10205 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
10206 focus_handle: cx.focus_handle(),
10207 }
10208 }
10209}
10210
10211impl Item for TestProjectItemView {
10212 type Event = ();
10213
10214 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
10215 "Test".into()
10216 }
10217}
10218
10219impl EventEmitter<()> for TestProjectItemView {}
10220
10221impl Focusable for TestProjectItemView {
10222 fn focus_handle(&self, _: &App) -> FocusHandle {
10223 self.focus_handle.clone()
10224 }
10225}
10226
10227impl Render for TestProjectItemView {
10228 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
10229 Empty
10230 }
10231}