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