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