ZIm/crates/project_panel/src/project_panel_tests.rs
Antonio Scandurra 545ae27079
Add the ability to follow the agent as it makes edits (#29839)
Nathan here: I also tacked on a bunch of UI refinement.

Release Notes:

- Introduced the ability to follow the agent around as it reads and
edits files.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-05-04 08:28:39 +00:00

5329 lines
164 KiB
Rust

use super::*;
use collections::HashSet;
use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
use pretty_assertions::assert_eq;
use project::{FakeFs, WorktreeSettings};
use serde_json::json;
use settings::SettingsStore;
use std::path::{Path, PathBuf};
use util::{path, separator};
use workspace::{
AppState, Pane,
item::{Item, ProjectItem},
register_project_item,
};
#[gpui::test]
async fn test_visible_list(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
"C": {
"5": {},
"6": { "V": "", "W": "" },
"7": { "X": "" },
"8": { "Y": {}, "Z": "" }
}
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"d": {
"9": ""
},
"e": {}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
toggle_expand_dir(&panel, "root1/b", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > .git",
" > a",
" v b <== selected",
" > 3",
" > 4",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
assert_eq!(
visible_entries_as_strings(&panel, 6..9, cx),
&[
//
" > C",
" .dockerignore",
"v root2",
]
);
}
#[gpui::test]
async fn test_opening_file(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/src"),
json!({
"test": {
"first.rs": "// First Rust file",
"second.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "src/test", cx);
select_path(&panel, "src/test/first.rs", cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" first.rs <== selected <== marked",
" second.rs",
" third.rs"
]
);
ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
select_path(&panel, "src/test/second.rs", cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" first.rs",
" second.rs <== selected <== marked",
" third.rs"
]
);
ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
}
#[gpui::test]
async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions =
Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
});
});
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root1",
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
"C": {
"5": {},
"6": { "V": "", "W": "" },
"7": { "X": "" },
"8": { "Y": {}, "Z": "" }
}
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"d": {
"4": ""
},
"e": {}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > a",
" > b",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
toggle_expand_dir(&panel, "root1/b", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > a",
" v b <== selected",
" > 3",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
toggle_expand_dir(&panel, "root2/d", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > a",
" v b",
" > 3",
" > C",
" .dockerignore",
"v root2",
" v d <== selected",
" > e",
]
);
toggle_expand_dir(&panel, "root2/e", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
"v root1",
" > a",
" v b",
" > 3",
" > C",
" .dockerignore",
"v root2",
" v d",
" v e <== selected",
]
);
}
#[gpui::test]
async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root1"),
json!({
"dir_1": {
"nested_dir_1": {
"nested_dir_2": {
"nested_dir_3": {
"file_a.java": "// File contents",
"file_b.java": "// File contents",
"file_c.java": "// File contents",
"nested_dir_4": {
"nested_dir_5": {
"file_d.java": "// File contents",
}
}
}
}
}
}
}),
)
.await;
fs.insert_tree(
path!("/root2"),
json!({
"dir_2": {
"file_1.java": "// File contents",
}
}),
)
.await;
let project = Project::test(
fs.clone(),
[path!("/root1").as_ref(), path!("/root2").as_ref()],
cx,
)
.await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
separator!("v root1"),
separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
separator!("v root2"),
separator!(" > dir_2"),
]
);
toggle_expand_dir(
&panel,
"root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
cx,
);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
separator!("v root1"),
separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"),
separator!(" > nested_dir_4/nested_dir_5"),
separator!(" file_a.java"),
separator!(" file_b.java"),
separator!(" file_c.java"),
separator!("v root2"),
separator!(" > dir_2"),
]
);
toggle_expand_dir(
&panel,
"root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
cx,
);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
separator!("v root1"),
separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
separator!(" v nested_dir_4/nested_dir_5 <== selected"),
separator!(" file_d.java"),
separator!(" file_a.java"),
separator!(" file_b.java"),
separator!(" file_c.java"),
separator!("v root2"),
separator!(" > dir_2"),
]
);
toggle_expand_dir(&panel, "root2/dir_2", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
separator!("v root1"),
separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
separator!(" v nested_dir_4/nested_dir_5"),
separator!(" file_d.java"),
separator!(" file_a.java"),
separator!(" file_b.java"),
separator!(" file_c.java"),
separator!("v root2"),
separator!(" v dir_2 <== selected"),
separator!(" file_1.java"),
]
);
}
#[gpui::test(iterations = 30)]
async fn test_editing_files(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
"C": {
"5": {},
"6": { "V": "", "W": "" },
"7": { "X": "" },
"8": { "Y": {}, "Z": "" }
}
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"d": {
"9": ""
},
"e": {}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "root1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1 <== selected",
" > .git",
" > a",
" > b",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
// Add a file with the root folder selected. The filename editor is placed
// before the first file in the root folder.
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" [EDITOR: ''] <== selected",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text("the-new-filename", window, cx)
});
panel.confirm_edit(window, cx).unwrap()
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" [PROCESSING: 'the-new-filename'] <== selected",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" .dockerignore",
" the-new-filename <== selected <== marked",
"v root2",
" > d",
" > e",
]
);
select_path(&panel, "root1/b", cx);
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" [EDITOR: ''] <== selected",
" > C",
" .dockerignore",
" the-new-filename",
]
);
panel
.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text("another-filename.txt", window, cx)
});
panel.confirm_edit(window, cx).unwrap()
})
.await
.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" another-filename.txt <== selected <== marked",
" > C",
" .dockerignore",
" the-new-filename",
]
);
select_path(&panel, "root1/b/another-filename.txt", cx);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" [EDITOR: 'another-filename.txt'] <== selected <== marked",
" > C",
" .dockerignore",
" the-new-filename",
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
let file_name_selections = editor.selections.all::<usize>(cx);
assert_eq!(
file_name_selections.len(),
1,
"File editing should have a single selection, but got: {file_name_selections:?}"
);
let file_name_selection = &file_name_selections[0];
assert_eq!(
file_name_selection.start, 0,
"Should select the file name from the start"
);
assert_eq!(
file_name_selection.end,
"another-filename".len(),
"Should not select file extension"
);
editor.set_text("a-different-filename.tar.gz", window, cx)
});
panel.confirm_edit(window, cx).unwrap()
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
" > C",
" .dockerignore",
" the-new-filename",
]
);
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" a-different-filename.tar.gz <== selected",
" > C",
" .dockerignore",
" the-new-filename",
]
);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" [EDITOR: 'a-different-filename.tar.gz'] <== selected",
" > C",
" .dockerignore",
" the-new-filename",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
let file_name_selections = editor.selections.all::<usize>(cx);
assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
let file_name_selection = &file_name_selections[0];
assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
});
panel.cancel(&menu::Cancel, window, cx)
});
panel.update_in(cx, |panel, window, cx| {
panel.new_directory(&NewDirectory, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > [EDITOR: ''] <== selected",
" a-different-filename.tar.gz",
" > C",
" .dockerignore",
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > [PROCESSING: 'new-dir']",
" a-different-filename.tar.gz <== selected",
" > C",
" .dockerignore",
]
);
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" > C",
" .dockerignore",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.rename(&Default::default(), window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > new-dir",
" [EDITOR: 'a-different-filename.tar.gz'] <== selected",
" > C",
" .dockerignore",
]
);
// Dismiss the rename editor when it loses focus.
workspace.update(cx, |_, window, _| window.blur()).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" > C",
" .dockerignore",
]
);
// Test empty filename and filename with only whitespace
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > new-dir",
" [EDITOR: ''] <== selected",
" a-different-filename.tar.gz",
" > C",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
assert!(panel.confirm_edit(window, cx).is_none());
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text(" ", window, cx);
});
assert!(panel.confirm_edit(window, cx).is_none());
panel.cancel(&menu::Cancel, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" v b",
" > 3",
" > 4",
" > new-dir",
" a-different-filename.tar.gz <== selected",
" > C",
" .dockerignore",
]
);
}
#[gpui::test(iterations = 10)]
async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
"C": {
"5": {},
"6": { "V": "", "W": "" },
"7": { "X": "" },
"8": { "Y": {}, "Z": "" }
}
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"d": {
"9": ""
},
"e": {}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "root1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1 <== selected",
" > .git",
" > a",
" > b",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
// Add a file with the root folder selected. The filename editor is placed
// before the first file in the root folder.
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" [EDITOR: ''] <== selected",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
});
panel.confirm_edit(window, cx).unwrap()
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" > C",
" [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..13, cx),
&[
"v root1",
" > .git",
" > a",
" > b",
" v bdir1",
" v dir2",
" the-new-filename <== selected <== marked",
" > C",
" .dockerignore",
"v root2",
" > d",
" > e",
]
);
}
#[gpui::test]
async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root1"),
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "root1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root1 <== selected", " > .git", " .dockerignore",]
);
// Add a file with the root folder selected. The filename editor is placed
// before the first file in the root folder.
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" [EDITOR: ''] <== selected",
" .dockerignore",
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
// If we want to create a subdirectory, there should be no prefix slash.
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" [PROCESSING: 'new_dir/'] <== selected",
" .dockerignore",
]
);
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" v new_dir <== selected",
" .dockerignore",
]
);
// Test filename with whitespace
select_path(&panel, "root1", cx);
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
let confirm = panel.update_in(cx, |panel, window, cx| {
// If we want to create a subdirectory, there should be no prefix slash.
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" v new dir 2 <== selected",
" v new_dir",
" .dockerignore",
]
);
// Test filename ends with "\"
#[cfg(target_os = "windows")]
{
select_path(&panel, "root1", cx);
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
let confirm = panel.update_in(cx, |panel, window, cx| {
// If we want to create a subdirectory, there should be no prefix slash.
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root1",
" > .git",
" v new dir 2",
" v new_dir",
" v new_dir_3 <== selected",
" .dockerignore",
]
);
}
}
#[gpui::test]
async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
"one.two.txt": "",
"one.txt": ""
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.select_next(&Default::default(), window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" one.txt <== selected",
" one.two.txt",
]
);
// Regression test - file name is created correctly when
// the copied file's name contains multiple dots.
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" one.txt",
" [EDITOR: 'one copy.txt'] <== selected <== marked",
" one.two.txt",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
let file_name_selections = editor.selections.all::<usize>(cx);
assert_eq!(
file_name_selections.len(),
1,
"File editing should have a single selection, but got: {file_name_selections:?}"
);
let file_name_selection = &file_name_selections[0];
assert_eq!(
file_name_selection.start,
"one".len(),
"Should select the file name disambiguation after the original file name"
);
assert_eq!(
file_name_selection.end,
"one copy".len(),
"Should select the file name disambiguation until the extension"
);
});
assert!(panel.confirm_edit(window, cx).is_none());
});
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" one.txt",
" one copy.txt",
" [EDITOR: 'one copy 1.txt'] <== selected <== marked",
" one.two.txt",
]
);
panel.update_in(cx, |panel, window, cx| {
assert!(panel.confirm_edit(window, cx).is_none())
});
}
#[gpui::test]
async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
"one.txt": "",
"two.txt": "",
"three.txt": "",
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"one.txt": "",
"two.txt": "",
"four.txt": "",
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
select_path(&panel, "root1/three.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.cut(&Default::default(), window, cx);
});
select_path(&panel, "root2/one.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" > a",
" one.txt",
" two.txt",
"v root2",
" > b",
" four.txt",
" one.txt",
" three.txt <== selected <== marked",
" two.txt",
]
);
select_path(&panel, "root1/a", cx);
panel.update_in(cx, |panel, window, cx| {
panel.cut(&Default::default(), window, cx);
});
select_path(&panel, "root2/two.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" one.txt",
" two.txt",
"v root2",
" > a <== selected",
" > b",
" four.txt",
" one.txt",
" three.txt <== marked",
" two.txt",
]
);
}
#[gpui::test]
async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
"one.txt": "",
"two.txt": "",
"three.txt": "",
"a": {
"0": { "q": "", "r": "", "s": "" },
"1": { "t": "", "u": "" },
"2": { "v": "", "w": "", "x": "", "y": "" },
},
}),
)
.await;
fs.insert_tree(
"/root2",
json!({
"one.txt": "",
"two.txt": "",
"four.txt": "",
"b": {
"3": { "Q": "" },
"4": { "R": "", "S": "", "T": "", "U": "" },
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
select_path(&panel, "root1/three.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
});
select_path(&panel, "root2/one.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" > a",
" one.txt",
" three.txt",
" two.txt",
"v root2",
" > b",
" four.txt",
" one.txt",
" three.txt <== selected <== marked",
" two.txt",
]
);
select_path(&panel, "root1/three.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
});
select_path(&panel, "root2/two.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" > a",
" one.txt",
" three.txt",
" two.txt",
"v root2",
" > b",
" four.txt",
" one.txt",
" three.txt",
" [EDITOR: 'three copy.txt'] <== selected <== marked",
" two.txt",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.cancel(&menu::Cancel {}, window, cx)
});
cx.executor().run_until_parked();
select_path(&panel, "root1/a", cx);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
});
select_path(&panel, "root2/two.txt", cx);
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root1",
" > a",
" one.txt",
" three.txt",
" two.txt",
"v root2",
" > a <== selected",
" > b",
" four.txt",
" one.txt",
" three.txt",
" three copy.txt",
" two.txt",
]
);
}
#[gpui::test]
async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"a": {
"one.txt": "",
"two.txt": "",
"inner_dir": {
"three.txt": "",
"four.txt": "",
}
},
"b": {}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
select_path(&panel, "root/a", cx);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
panel.select_next(&Default::default(), window, cx);
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
assert_ne!(
pasted_dir_file, None,
"Pasted directory file should have an entry"
);
let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
assert_ne!(
pasted_dir_inner_dir, None,
"Directories inside pasted directory should have an entry"
);
toggle_expand_dir(&panel, "root/b/a", cx);
toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root",
" > a",
" v b",
" v a",
" v inner_dir <== selected",
" four.txt",
" three.txt",
" one.txt",
" two.txt",
]
);
select_path(&panel, "root", cx);
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx)
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root",
" > a",
" > [EDITOR: 'a copy'] <== selected",
" v b",
" v a",
" v inner_dir",
" four.txt",
" three.txt",
" one.txt",
" two.txt"
]
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("c", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root",
" > a",
" > [PROCESSING: 'c'] <== selected",
" v b",
" v a",
" v inner_dir",
" four.txt",
" three.txt",
" one.txt",
" two.txt"
]
);
confirm.await.unwrap();
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx)
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..50, cx),
&[
//
"v root",
" > a",
" v b",
" v a",
" v inner_dir",
" four.txt",
" three.txt",
" one.txt",
" two.txt",
" v c",
" > a <== selected",
" > inner_dir",
" one.txt",
" two.txt",
]
);
}
#[gpui::test]
async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/test",
json!({
"dir1": {
"a.txt": "",
"b.txt": "",
},
"dir2": {},
"c.txt": "",
"d.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "test/dir1", cx);
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "test/dir1", cx);
select_path_with_mark(&panel, "test/c.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v test",
" v dir1 <== marked",
" a.txt",
" b.txt",
" > dir2",
" c.txt <== selected <== marked",
" d.txt",
],
"Initial state before copying dir1 and c.txt"
);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
});
select_path(&panel, "test/dir2", cx);
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
toggle_expand_dir(&panel, "test/dir2/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v test",
" v dir1 <== marked",
" a.txt",
" b.txt",
" v dir2",
" v dir1 <== selected",
" a.txt",
" b.txt",
" c.txt",
" c.txt <== marked",
" d.txt",
],
"Should copy dir1 as well as c.txt into dir2"
);
// Disambiguating multiple files should not open the rename editor.
select_path(&panel, "test/dir2", cx);
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v test",
" v dir1 <== marked",
" a.txt",
" b.txt",
" v dir2",
" v dir1",
" a.txt",
" b.txt",
" > dir1 copy <== selected",
" c.txt",
" c copy.txt",
" c.txt <== marked",
" d.txt",
],
"Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
);
}
#[gpui::test]
async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/test",
json!({
"dir1": {
"a.txt": "",
"b.txt": "",
},
"dir2": {},
"c.txt": "",
"d.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "test/dir1", cx);
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "test/dir1/a.txt", cx);
select_path_with_mark(&panel, "test/dir1", cx);
select_path_with_mark(&panel, "test/c.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v test",
" v dir1 <== marked",
" a.txt <== marked",
" b.txt",
" > dir2",
" c.txt <== selected <== marked",
" d.txt",
],
"Initial state before copying a.txt, dir1 and c.txt"
);
panel.update_in(cx, |panel, window, cx| {
panel.copy(&Default::default(), window, cx);
});
select_path(&panel, "test/dir2", cx);
panel.update_in(cx, |panel, window, cx| {
panel.paste(&Default::default(), window, cx);
});
cx.executor().run_until_parked();
toggle_expand_dir(&panel, "test/dir2/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v test",
" v dir1 <== marked",
" a.txt <== marked",
" b.txt",
" v dir2",
" v dir1 <== selected",
" a.txt",
" b.txt",
" c.txt",
" c.txt <== marked",
" d.txt",
],
"Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
);
}
#[gpui::test]
async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/src"),
json!({
"test": {
"first.rs": "// First Rust file",
"second.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "src/test", cx);
select_path(&panel, "src/test/first.rs", cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" first.rs <== selected <== marked",
" second.rs",
" third.rs"
]
);
ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" second.rs <== selected",
" third.rs"
],
"Project panel should have no deleted file, no other file is selected in it"
);
ensure_no_open_items_and_panes(&workspace, cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" second.rs <== selected <== marked",
" third.rs"
]
);
ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
workspace
.update(cx, |workspace, window, cx| {
let active_items = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item())
.collect::<Vec<_>>();
assert_eq!(active_items.len(), 1);
let open_editor = active_items
.into_iter()
.next()
.unwrap()
.downcast::<Editor>()
.expect("Open item should be an editor");
open_editor.update(cx, |editor, cx| {
editor.set_text("Another text!", window, cx)
});
})
.unwrap();
submit_deletion_skipping_prompt(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v src", " v test", " third.rs <== selected"],
"Project panel should have no deleted file, with one last file remaining"
);
ensure_no_open_items_and_panes(&workspace, cx);
}
#[gpui::test]
async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/src",
json!({
"test": {
"first.rs": "// First Rust file",
"second.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "src/", cx);
panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src <== selected",
" > test"
]
);
panel.update_in(cx, |panel, window, cx| {
panel.new_directory(&NewDirectory, window, cx)
});
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src",
" > [EDITOR: ''] <== selected",
" > test"
]
);
panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("test", window, cx));
assert!(
panel.confirm_edit(window, cx).is_none(),
"Should not allow to confirm on conflicting new directory name"
);
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting new directory name"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src <== selected",
" > test"
],
"File list should be unchanged after failed folder create confirmation"
);
select_path(&panel, "src/test/", cx);
panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src",
" > test <== selected"
]
);
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" [EDITOR: ''] <== selected",
" first.rs",
" second.rs",
" third.rs"
]
);
panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
assert!(
panel.confirm_edit(window, cx).is_none(),
"Should not allow to confirm on conflicting new file name"
);
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting new file name"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test <== selected",
" first.rs",
" second.rs",
" third.rs"
],
"File list should be unchanged after failed file create confirmation"
);
select_path(&panel, "src/test/first.rs", cx);
panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" first.rs <== selected",
" second.rs",
" third.rs"
],
);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" [EDITOR: 'first.rs'] <== selected",
" second.rs",
" third.rs"
]
);
panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
assert!(
panel.confirm_edit(window, cx).is_none(),
"Should not allow to confirm on conflicting file rename"
)
});
cx.executor().run_until_parked();
panel.update_in(cx, |panel, window, cx| {
assert!(
panel.edit_state.is_some(),
"Edit state should not be None after conflicting file rename"
);
panel.cancel(&menu::Cancel, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v src",
" v test",
" first.rs <== selected",
" second.rs",
" third.rs"
],
"File list should be unchanged after failed rename confirmation"
);
}
#[gpui::test]
async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"tree1": {
".git": {},
"dir1": {
"modified1.txt": "1",
"unmodified1.txt": "1",
"modified2.txt": "1",
},
"dir2": {
"modified3.txt": "1",
"unmodified2.txt": "1",
},
"modified4.txt": "1",
"unmodified3.txt": "1",
},
"tree2": {
".git": {},
"dir3": {
"modified5.txt": "1",
"unmodified4.txt": "1",
},
"modified6.txt": "1",
"unmodified5.txt": "1",
}
}),
)
.await;
// Mark files as git modified
fs.set_git_content_for_repo(
path!("/root/tree1/.git").as_ref(),
&[
("dir1/modified1.txt".into(), "modified".into(), None),
("dir1/modified2.txt".into(), "modified".into(), None),
("modified4.txt".into(), "modified".into(), None),
("dir2/modified3.txt".into(), "modified".into(), None),
],
);
fs.set_git_content_for_repo(
path!("/root/tree2/.git").as_ref(),
&[
("dir3/modified5.txt".into(), "modified".into(), None),
("modified6.txt".into(), "modified".into(), None),
],
);
let project = Project::test(
fs.clone(),
[path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
cx,
)
.await;
let (scan1_complete, scan2_complete) = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx);
let worktree1 = worktrees.next().unwrap();
let worktree2 = worktrees.next().unwrap();
(
worktree1.read(cx).as_local().unwrap().scan_complete(),
worktree2.read(cx).as_local().unwrap().scan_complete(),
)
});
scan1_complete.await;
scan2_complete.await;
cx.run_until_parked();
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Check initial state
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v tree1",
" > .git",
" > dir1",
" > dir2",
" modified4.txt",
" unmodified3.txt",
"v tree2",
" > .git",
" > dir3",
" modified6.txt",
" unmodified5.txt"
],
);
// Test selecting next modified entry
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..6, cx),
&[
"v tree1",
" > .git",
" v dir1",
" modified1.txt <== selected",
" modified2.txt",
" unmodified1.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..6, cx),
&[
"v tree1",
" > .git",
" v dir1",
" modified1.txt",
" modified2.txt <== selected",
" unmodified1.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 6..9, cx),
&[
" v dir2",
" modified3.txt <== selected",
" unmodified2.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 9..11, cx),
&[" modified4.txt <== selected", " unmodified3.txt",],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 13..16, cx),
&[
" v dir3",
" modified5.txt <== selected",
" unmodified4.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 16..18, cx),
&[" modified6.txt <== selected", " unmodified5.txt",],
);
// Wraps around to first modified file
panel.update_in(cx, |panel, window, cx| {
panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..18, cx),
&[
"v tree1",
" > .git",
" v dir1",
" modified1.txt <== selected",
" modified2.txt",
" unmodified1.txt",
" v dir2",
" modified3.txt",
" unmodified2.txt",
" modified4.txt",
" unmodified3.txt",
"v tree2",
" > .git",
" v dir3",
" modified5.txt",
" unmodified4.txt",
" modified6.txt",
" unmodified5.txt",
],
);
// Wraps around again to last modified file
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 16..18, cx),
&[" modified6.txt <== selected", " unmodified5.txt",],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 13..16, cx),
&[
" v dir3",
" modified5.txt <== selected",
" unmodified4.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 9..11, cx),
&[" modified4.txt <== selected", " unmodified3.txt",],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 6..9, cx),
&[
" v dir2",
" modified3.txt <== selected",
" unmodified2.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..6, cx),
&[
"v tree1",
" > .git",
" v dir1",
" modified1.txt",
" modified2.txt <== selected",
" unmodified1.txt",
],
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..6, cx),
&[
"v tree1",
" > .git",
" v dir1",
" modified1.txt <== selected",
" modified2.txt",
" unmodified1.txt",
],
);
}
#[gpui::test]
async fn test_select_directory(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
}
},
"file_1.py": "# File contents",
"dir_2": {
},
"dir_3": {
},
"file_2.py": "# File contents",
"dir_4": {
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
select_path(&panel, "project_root/dir_1", cx);
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" > dir_1 <== selected",
" > dir_2",
" > dir_3",
" > dir_4",
" file_1.py",
" file_2.py",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_directory(&SelectPrevDirectory, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root <== selected",
" > dir_1",
" > dir_2",
" > dir_3",
" > dir_4",
" file_1.py",
" file_2.py",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.select_prev_directory(&SelectPrevDirectory, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" > dir_1",
" > dir_2",
" > dir_3",
" > dir_4 <== selected",
" file_1.py",
" file_2.py",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.select_next_directory(&SelectNextDirectory, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root <== selected",
" > dir_1",
" > dir_2",
" > dir_3",
" > dir_4",
" file_1.py",
" file_2.py",
]
);
}
#[gpui::test]
async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
}
},
"file_1.py": "# File contents",
"file_2.py": "# File contents",
"zdir_2": {
"nested_dir2": {
"file_b.py": "# File contents",
}
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" > dir_1",
" > zdir_2",
" file_1.py",
" file_2.py",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.select_first(&SelectFirst, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root <== selected",
" > dir_1",
" > zdir_2",
" file_1.py",
" file_2.py",
]
);
panel.update_in(cx, |panel, window, cx| {
panel.select_last(&SelectLast, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" > dir_1",
" > zdir_2",
" file_1.py",
" file_2.py <== selected",
]
);
}
#[gpui::test]
async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
}
},
"file_1.py": "# File contents",
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
select_path(&panel, "project_root/dir_1", cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
select_path(&panel, "project_root/dir_1/nested_dir", cx);
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" > nested_dir <== selected",
" file_1.py",
]
);
}
#[gpui::test]
async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
"file_1.py": "# File contents",
"file_2.py": "# File contents",
"file_3.py": "# File contents",
},
"dir_2": {
"file_1.py": "# File contents",
"file_2.py": "# File contents",
"file_3.py": "# File contents",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
panel.update_in(cx, |panel, window, cx| {
panel.collapse_all_entries(&CollapseAllEntries, window, cx)
});
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v project_root", " > dir_1", " > dir_2",]
);
// Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
toggle_expand_dir(&panel, "project_root/dir_1", cx);
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1 <== selected",
" > nested_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" > dir_2",
]
);
}
#[gpui::test]
async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.as_fake().insert_tree(path!("/root"), json!({})).await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Make a new buffer with no backing file
workspace
.update(cx, |workspace, window, cx| {
Editor::new_file(workspace, &Default::default(), window, cx)
})
.unwrap();
cx.executor().run_until_parked();
// "Save as" the buffer, creating a new backing file for it
let save_task = workspace
.update(cx, |workspace, window, cx| {
workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
})
.unwrap();
cx.executor().run_until_parked();
cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
save_task.await.unwrap();
// Rename the file
select_path(&panel, "root/new", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " new <== selected <== marked"]
);
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("newer", window, cx));
});
panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " newer <== selected"]
);
workspace
.update(cx, |workspace, window, cx| {
workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
})
.unwrap()
.await
.unwrap();
cx.executor().run_until_parked();
// assert that saving the file doesn't restore "new"
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " newer <== selected"]
);
}
#[gpui::test]
#[cfg_attr(target_os = "windows", ignore)]
async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
"dir1": {
"file1.txt": "content 1",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root1/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root1", " v dir1 <== selected", " file1.txt",],
"Initial state with worktrees"
);
select_path(&panel, "root1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root1 <== selected", " v dir1", " file1.txt",],
);
// Rename root1 to new_root1
panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v [EDITOR: 'root1'] <== selected",
" v dir1",
" file1.txt",
],
);
let confirm = panel.update_in(cx, |panel, window, cx| {
panel
.filename_editor
.update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
panel.confirm_edit(window, cx).unwrap()
});
confirm.await.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v new_root1 <== selected",
" v dir1",
" file1.txt",
],
"Should update worktree name"
);
// Ensure internal paths have been updated
select_path(&panel, "new_root1/dir1/file1.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v new_root1",
" v dir1",
" file1.txt <== selected",
],
"Files in renamed worktree are selectable"
);
}
#[gpui::test]
async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/project_root",
json!({
"dir_1": {
"nested_dir": {
"file_a.py": "# File contents",
}
},
"file_1.py": "# File contents",
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.select_next(&Default::default(), window, cx);
this.expand_selected_entry(&Default::default(), window, cx);
this.expand_selected_entry(&Default::default(), window, cx);
this.select_next(&Default::default(), window, cx);
this.expand_selected_entry(&Default::default(), window, cx);
this.select_next(&Default::default(), window, cx);
})
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_a.py <== selected",
" file_1.py",
]
);
let modifiers_with_shift = gpui::Modifiers {
shift: true,
..Default::default()
};
cx.run_until_parked();
cx.simulate_modifiers_change(modifiers_with_shift);
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.select_next(&Default::default(), window, cx);
})
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_a.py",
" file_1.py <== selected <== marked",
]
);
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.select_previous(&Default::default(), window, cx);
})
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_a.py <== selected <== marked",
" file_1.py <== marked",
]
);
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
let drag = DraggedSelection {
active_selection: this.selection.unwrap(),
marked_selections: Arc::new(this.marked_entries.clone()),
};
let target_entry = this
.project
.read(cx)
.entry_for_path(&(worktree_id, "").into(), cx)
.unwrap();
this.drag_onto(&drag, target_entry.id, false, window, cx);
});
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_1.py <== marked",
" file_a.py <== selected <== marked",
]
);
// ESC clears out all marks
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.cancel(&menu::Cancel, window, cx);
})
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_1.py",
" file_a.py <== selected",
]
);
// ESC clears out all marks
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.select_previous(&SelectPrevious, window, cx);
this.select_next(&SelectNext, window, cx);
})
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_1.py <== marked",
" file_a.py <== selected <== marked",
]
);
cx.simulate_modifiers_change(Default::default());
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.cut(&Cut, window, cx);
this.select_previous(&SelectPrevious, window, cx);
this.select_previous(&SelectPrevious, window, cx);
this.paste(&Paste, window, cx);
// this.expand_selected_entry(&ExpandSelectedEntry, cx);
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir",
" file_1.py <== marked",
" file_a.py <== selected <== marked",
]
);
cx.simulate_modifiers_change(modifiers_with_shift);
cx.update(|window, cx| {
panel.update(cx, |this, cx| {
this.expand_selected_entry(&Default::default(), window, cx);
this.select_next(&SelectNext, window, cx);
this.select_next(&SelectNext, window, cx);
})
});
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v project_root",
" v dir_1",
" v nested_dir <== selected",
]
);
}
#[gpui::test]
async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions = Some(Vec::new());
});
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_reveal_entries = Some(false)
});
})
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/project_root",
json!({
".git": {},
".gitignore": "**/gitignored_dir",
"dir_1": {
"file_1.py": "# File 1_1 contents",
"file_2.py": "# File 1_2 contents",
"file_3.py": "# File 1_3 contents",
"gitignored_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
},
"dir_2": {
"file_1.py": "# File 2_1 contents",
"file_2.py": "# File 2_2 contents",
"file_3.py": "# File 2_3 contents",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1",
" > dir_2",
" .gitignore",
]
);
let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
.expect("dir 1 file is not ignored and should have an entry");
let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
.expect("dir 2 file is not ignored and should have an entry");
let gitignored_dir_file =
find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
assert_eq!(
gitignored_dir_file, None,
"File in the gitignored dir should not have an entry before its dir is toggled"
);
toggle_expand_dir(&panel, "project_root/dir_1", cx);
toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" v gitignored_dir <== selected",
" file_a.py",
" file_b.py",
" file_c.py",
" file_1.py",
" file_2.py",
" file_3.py",
" > dir_2",
" .gitignore",
],
"Should show gitignored dir file list in the project panel"
);
let gitignored_dir_file =
find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
.expect("after gitignored dir got opened, a file entry should be present");
toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
toggle_expand_dir(&panel, "project_root/dir_1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1 <== selected",
" > dir_2",
" .gitignore",
],
"Should hide all dir contents again and prepare for the auto reveal test"
);
for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1 <== selected",
" > dir_2",
" .gitignore",
],
"When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
);
}
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_reveal_entries = Some(true)
});
})
});
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" > gitignored_dir",
" file_1.py <== selected <== marked",
" file_2.py",
" file_3.py",
" > dir_2",
" .gitignore",
],
"When auto reveal is enabled, not ignored dir_1 entry should be revealed"
);
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" > gitignored_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" v dir_2",
" file_1.py <== selected <== marked",
" file_2.py",
" file_3.py",
" .gitignore",
],
"When auto reveal is enabled, not ignored dir_2 entry should be revealed"
);
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(
gitignored_dir_file,
)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" > gitignored_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" v dir_2",
" file_1.py <== selected <== marked",
" file_2.py",
" file_3.py",
" .gitignore",
],
"When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
);
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" v gitignored_dir",
" file_a.py <== selected <== marked",
" file_b.py",
" file_c.py",
" file_1.py",
" file_2.py",
" file_3.py",
" v dir_2",
" file_1.py",
" file_2.py",
" file_3.py",
" .gitignore",
],
"When a gitignored entry is explicitly revealed, it should be shown in the project tree"
);
}
#[gpui::test]
async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions = Some(Vec::new());
worktree_settings.file_scan_inclusions =
Some(vec!["always_included_but_ignored_dir/*".to_string()]);
});
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_reveal_entries = Some(false)
});
})
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/project_root",
json!({
".git": {},
".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
"dir_1": {
"file_1.py": "# File 1_1 contents",
"file_2.py": "# File 1_2 contents",
"file_3.py": "# File 1_3 contents",
"gitignored_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
},
"dir_2": {
"file_1.py": "# File 2_1 contents",
"file_2.py": "# File 2_2 contents",
"file_3.py": "# File 2_3 contents",
},
"always_included_but_ignored_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > always_included_but_ignored_dir",
" > dir_1",
" > dir_2",
" .gitignore",
]
);
let gitignored_dir_file =
find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
let always_included_but_ignored_dir_file = find_project_entry(
&panel,
"project_root/always_included_but_ignored_dir/file_a.py",
cx,
)
.expect("file that is .gitignored but set to always be included should have an entry");
assert_eq!(
gitignored_dir_file, None,
"File in the gitignored dir should not have an entry unless its directory is toggled"
);
toggle_expand_dir(&panel, "project_root/dir_1", cx);
cx.run_until_parked();
cx.update(|_, cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_reveal_entries = Some(true)
});
})
});
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(
always_included_but_ignored_dir_file,
)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v always_included_but_ignored_dir",
" file_a.py <== selected <== marked",
" file_b.py",
" file_c.py",
" v dir_1",
" > gitignored_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" > dir_2",
" .gitignore",
],
"When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
);
}
#[gpui::test]
async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions = Some(Vec::new());
});
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_reveal_entries = Some(false)
});
})
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/project_root",
json!({
".git": {},
".gitignore": "**/gitignored_dir",
"dir_1": {
"file_1.py": "# File 1_1 contents",
"file_2.py": "# File 1_2 contents",
"file_3.py": "# File 1_3 contents",
"gitignored_dir": {
"file_a.py": "# File contents",
"file_b.py": "# File contents",
"file_c.py": "# File contents",
},
},
"dir_2": {
"file_1.py": "# File 2_1 contents",
"file_2.py": "# File 2_2 contents",
"file_3.py": "# File 2_3 contents",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1",
" > dir_2",
" .gitignore",
]
);
let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
.expect("dir 1 file is not ignored and should have an entry");
let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
.expect("dir 2 file is not ignored and should have an entry");
let gitignored_dir_file =
find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
assert_eq!(
gitignored_dir_file, None,
"File in the gitignored dir should not have an entry before its dir is toggled"
);
toggle_expand_dir(&panel, "project_root/dir_1", cx);
toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" v gitignored_dir <== selected",
" file_a.py",
" file_b.py",
" file_c.py",
" file_1.py",
" file_2.py",
" file_3.py",
" > dir_2",
" .gitignore",
],
"Should show gitignored dir file list in the project panel"
);
let gitignored_dir_file =
find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
.expect("after gitignored dir got opened, a file entry should be present");
toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
toggle_expand_dir(&panel, "project_root/dir_1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1 <== selected",
" > dir_2",
" .gitignore",
],
"Should hide all dir contents again and prepare for the explicit reveal test"
);
for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" > dir_1 <== selected",
" > dir_2",
" .gitignore",
],
"When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
);
}
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" > gitignored_dir",
" file_1.py <== selected <== marked",
" file_2.py",
" file_3.py",
" > dir_2",
" .gitignore",
],
"With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
);
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" > gitignored_dir",
" file_1.py",
" file_2.py",
" file_3.py",
" v dir_2",
" file_1.py <== selected <== marked",
" file_2.py",
" file_3.py",
" .gitignore",
],
"With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
);
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
})
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v project_root",
" > .git",
" v dir_1",
" v gitignored_dir",
" file_a.py <== selected <== marked",
" file_b.py",
" file_c.py",
" file_1.py",
" file_2.py",
" file_3.py",
" v dir_2",
" file_1.py",
" file_2.py",
" file_3.py",
" .gitignore",
],
"With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
);
}
#[gpui::test]
async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
project_settings.file_scan_exclusions =
Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
});
});
});
cx.update(|cx| {
register_project_item::<TestProjectItemView>(cx);
});
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root1",
json!({
".dockerignore": "",
".git": {
"HEAD": "",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "root1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root1 <== selected", " .dockerignore",]
);
workspace
.update(cx, |workspace, _, cx| {
assert!(
workspace.active_item(cx).is_none(),
"Should have no active items in the beginning"
);
})
.unwrap();
let excluded_file_path = ".git/COMMIT_EDITMSG";
let excluded_dir_path = "excluded_dir";
panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
panel
.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text(excluded_file_path, window, cx)
});
panel.confirm_edit(window, cx).unwrap()
})
.await
.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..13, cx),
&["v root1", " .dockerignore"],
"Excluded dir should not be shown after opening a file in it"
);
panel.update_in(cx, |panel, window, cx| {
assert!(
!panel.filename_editor.read(cx).is_focused(window),
"Should have closed the file name editor"
);
});
workspace
.update(cx, |workspace, _, cx| {
let active_entry_path = workspace
.active_item(cx)
.expect("should have opened and activated the excluded item")
.act_as::<TestProjectItemView>(cx)
.expect("should have opened the corresponding project item for the excluded item")
.read(cx)
.path
.clone();
assert_eq!(
active_entry_path.path.as_ref(),
Path::new(excluded_file_path),
"Should open the excluded file"
);
assert!(
workspace.notification_ids().is_empty(),
"Should have no notifications after opening an excluded file"
);
})
.unwrap();
assert!(
fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
"Should have created the excluded file"
);
select_path(&panel, "root1", cx);
panel.update_in(cx, |panel, window, cx| {
panel.new_directory(&NewDirectory, window, cx)
});
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
panel
.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text(excluded_file_path, window, cx)
});
panel.confirm_edit(window, cx).unwrap()
})
.await
.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..13, cx),
&["v root1", " .dockerignore"],
"Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
);
panel.update_in(cx, |panel, window, cx| {
assert!(
!panel.filename_editor.read(cx).is_focused(window),
"Should have closed the file name editor"
);
});
workspace
.update(cx, |workspace, _, cx| {
let notifications = workspace.notification_ids();
assert_eq!(
notifications.len(),
1,
"Should receive one notification with the error message"
);
workspace.dismiss_notification(notifications.first().unwrap(), cx);
assert!(workspace.notification_ids().is_empty());
})
.unwrap();
select_path(&panel, "root1", cx);
panel.update_in(cx, |panel, window, cx| {
panel.new_directory(&NewDirectory, window, cx)
});
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
panel
.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text(excluded_dir_path, window, cx)
});
panel.confirm_edit(window, cx).unwrap()
})
.await
.unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..13, cx),
&["v root1", " .dockerignore"],
"Should not change the project panel after trying to create an excluded directory"
);
panel.update_in(cx, |panel, window, cx| {
assert!(
!panel.filename_editor.read(cx).is_focused(window),
"Should have closed the file name editor"
);
});
workspace
.update(cx, |workspace, _, cx| {
let notifications = workspace.notification_ids();
assert_eq!(
notifications.len(),
1,
"Should receive one notification explaining that no directory is actually shown"
);
workspace.dismiss_notification(notifications.first().unwrap(), cx);
assert!(workspace.notification_ids().is_empty());
})
.unwrap();
assert!(
fs.is_dir(Path::new("/root1/excluded_dir")).await,
"Should have created the excluded directory"
);
}
#[gpui::test]
async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/src",
json!({
"test": {
"first.rs": "// First Rust file",
"second.rs": "// Second Rust file",
"third.rs": "// Third Rust file",
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
select_path(&panel, "src/", cx);
panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
cx.executor().run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src <== selected",
" > test"
]
);
panel.update_in(cx, |panel, window, cx| {
panel.new_directory(&NewDirectory, window, cx)
});
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src",
" > [EDITOR: ''] <== selected",
" > test"
]
);
panel.update_in(cx, |panel, window, cx| {
panel.cancel(&menu::Cancel, window, cx)
});
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
//
"v src <== selected",
" > test"
]
);
}
#[gpui::test]
async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"subdir1": {},
"file1.txt": "",
"file2.txt": "",
},
"dir2": {
"subdir2": {},
"file3.txt": "",
"file4.txt": "",
},
"file5.txt": "",
"file6.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir2", cx);
// Test Case 1: Delete middle file in directory
select_path(&panel, "root/dir1/file1.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" > subdir1",
" file1.txt <== selected",
" file2.txt",
" v dir2",
" > subdir2",
" file3.txt",
" file4.txt",
" file5.txt",
" file6.txt",
],
"Initial state before deleting middle file"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" > subdir1",
" file2.txt <== selected",
" v dir2",
" > subdir2",
" file3.txt",
" file4.txt",
" file5.txt",
" file6.txt",
],
"Should select next file after deleting middle file"
);
// Test Case 2: Delete last file in directory
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" > subdir1 <== selected",
" v dir2",
" > subdir2",
" file3.txt",
" file4.txt",
" file5.txt",
" file6.txt",
],
"Should select next directory when last file is deleted"
);
// Test Case 3: Delete root level file
select_path(&panel, "root/file6.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" > subdir1",
" v dir2",
" > subdir2",
" file3.txt",
" file4.txt",
" file5.txt",
" file6.txt <== selected",
],
"Initial state before deleting root level file"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" > subdir1",
" v dir2",
" > subdir2",
" file3.txt",
" file4.txt",
" file5.txt <== selected",
],
"Should select prev entry at root level"
);
}
#[gpui::test]
async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"aa": "// Testing 1",
"bb": "// Testing 2",
"cc": "// Testing 3",
"dd": "// Testing 4",
"ee": "// Testing 5",
"ff": "// Testing 6",
"gg": "// Testing 7",
"hh": "// Testing 8",
"ii": "// Testing 8",
".gitignore": "bb\ndd\nee\nff\nii\n'",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Test 1: Auto selection with one gitignored file next to the deleted file
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
hide_gitignore: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
select_path(&panel, "root/aa", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" .gitignore",
" aa <== selected",
" cc",
" gg",
" hh"
],
"Initial state should hide files on .gitignore"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" .gitignore",
" cc <== selected",
" gg",
" hh"
],
"Should select next entry not on .gitignore"
);
// Test 2: Auto selection with many gitignored files next to the deleted file
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" .gitignore",
" gg <== selected",
" hh"
],
"Should select next entry not on .gitignore"
);
// Test 3: Auto selection of entry before deleted file
select_path(&panel, "root/hh", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" .gitignore",
" gg",
" hh <== selected"
],
"Should select next entry not on .gitignore"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&["v root", " .gitignore", " gg <== selected"],
"Should select next entry not on .gitignore"
);
}
#[gpui::test]
async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"dir1": {
"file1": "// Testing",
"file2": "// Testing",
"file3": "// Testing"
},
"aa": "// Testing",
".gitignore": "file1\nfile3\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
hide_gitignore: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Test 1: Visible items should exclude files on gitignore
toggle_expand_dir(&panel, "root/dir1", cx);
select_path(&panel, "root/dir1/file2", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" v dir1",
" file2 <== selected",
" .gitignore",
" aa"
],
"Initial state should hide files on .gitignore"
);
submit_deletion(&panel, cx);
// Test 2: Auto selection should go to the parent
assert_eq!(
visible_entries_as_strings(&panel, 0..10, cx),
&[
"v root",
" v dir1 <== selected",
" .gitignore",
" aa"
],
"Initial state should hide files on .gitignore"
);
}
#[gpui::test]
async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"subdir1": {
"a.txt": "",
"b.txt": ""
},
"file1.txt": "",
},
"dir2": {
"subdir2": {
"c.txt": "",
"d.txt": ""
},
"file2.txt": "",
},
"file3.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir2", cx);
toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
// Test Case 1: Select and delete nested directory with parent
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "root/dir1/subdir1", cx);
select_path_with_mark(&panel, "root/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1 <== selected <== marked",
" v subdir1 <== marked",
" a.txt",
" b.txt",
" file1.txt",
" v dir2",
" v subdir2",
" c.txt",
" d.txt",
" file2.txt",
" file3.txt",
],
"Initial state before deleting nested directory with parent"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir2 <== selected",
" v subdir2",
" c.txt",
" d.txt",
" file2.txt",
" file3.txt",
],
"Should select next directory after deleting directory with parent"
);
// Test Case 2: Select mixed files and directories across levels
select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
select_path_with_mark(&panel, "root/file3.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir2",
" v subdir2",
" c.txt <== marked",
" d.txt",
" file2.txt <== marked",
" file3.txt <== selected <== marked",
],
"Initial state before deleting"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir2 <== selected",
" v subdir2",
" d.txt",
],
"Should select sibling directory"
);
}
#[gpui::test]
async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"subdir1": {
"a.txt": "",
"b.txt": ""
},
"file1.txt": "",
},
"dir2": {
"subdir2": {
"c.txt": "",
"d.txt": ""
},
"file2.txt": "",
},
"file3.txt": "",
"file4.txt": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir2", cx);
toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
// Test Case 1: Select all root files and directories
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "root/dir1", cx);
select_path_with_mark(&panel, "root/dir2", cx);
select_path_with_mark(&panel, "root/file3.txt", cx);
select_path_with_mark(&panel, "root/file4.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root",
" v dir1 <== marked",
" v subdir1",
" a.txt",
" b.txt",
" file1.txt",
" v dir2 <== marked",
" v subdir2",
" c.txt",
" d.txt",
" file2.txt",
" file3.txt <== marked",
" file4.txt <== selected <== marked",
],
"State before deleting all contents"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root <== selected"],
"Only empty root directory should remain after deleting all contents"
);
}
#[gpui::test]
async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"subdir1": {
"file_a.txt": "content a",
"file_b.txt": "content b",
},
"subdir2": {
"file_c.txt": "content c",
},
"file1.txt": "content 1",
},
"dir2": {
"file2.txt": "content 2",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir2", cx);
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
// Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
select_path_with_mark(&panel, "root/dir1", cx);
select_path_with_mark(&panel, "root/dir1/subdir1", cx);
select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root",
" v dir1 <== marked",
" v subdir1 <== marked",
" file_a.txt <== selected <== marked",
" file_b.txt",
" > subdir2",
" file1.txt",
" v dir2",
" file2.txt",
],
"State with parent dir, subdir, and file selected"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root", " v dir2 <== selected", " file2.txt",],
"Only dir2 should remain after deletion"
);
}
#[gpui::test]
async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
// First worktree
fs.insert_tree(
"/root1",
json!({
"dir1": {
"file1.txt": "content 1",
"file2.txt": "content 2",
},
"dir2": {
"file3.txt": "content 3",
},
}),
)
.await;
// Second worktree
fs.insert_tree(
"/root2",
json!({
"dir3": {
"file4.txt": "content 4",
"file5.txt": "content 5",
},
"file6.txt": "content 6",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Expand all directories for testing
toggle_expand_dir(&panel, "root1/dir1", cx);
toggle_expand_dir(&panel, "root1/dir2", cx);
toggle_expand_dir(&panel, "root2/dir3", cx);
// Test Case 1: Delete files across different worktrees
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root1",
" v dir1",
" file1.txt <== marked",
" file2.txt",
" v dir2",
" file3.txt",
"v root2",
" v dir3",
" file4.txt <== selected <== marked",
" file5.txt",
" file6.txt",
],
"Initial state with files selected from different worktrees"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root1",
" v dir1",
" file2.txt",
" v dir2",
" file3.txt",
"v root2",
" v dir3",
" file5.txt <== selected",
" file6.txt",
],
"Should select next file in the last worktree after deletion"
);
// Test Case 2: Delete directories from different worktrees
select_path_with_mark(&panel, "root1/dir1", cx);
select_path_with_mark(&panel, "root2/dir3", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root1",
" v dir1 <== marked",
" file2.txt",
" v dir2",
" file3.txt",
"v root2",
" v dir3 <== selected <== marked",
" file5.txt",
" file6.txt",
],
"State with directories marked from different worktrees"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root1",
" v dir2",
" file3.txt",
"v root2",
" file6.txt <== selected",
],
"Should select remaining file in last worktree after directory deletion"
);
// Test Case 4: Delete all remaining files except roots
select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
select_path_with_mark(&panel, "root2/file6.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root1",
" v dir2",
" file3.txt <== marked",
"v root2",
" file6.txt <== selected <== marked",
],
"State with all remaining files marked"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root1", " v dir2", "v root2 <== selected"],
"Second parent root should be selected after deleting"
);
}
#[gpui::test]
async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"dir1": {
"file1.txt": "",
"file2.txt": "",
"file3.txt": "",
},
"dir2": {
"file4.txt": "",
"file5.txt": "",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir2", cx);
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
select_path(&panel, "root/dir1/file1.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" file1.txt <== selected",
" file2.txt <== marked",
" file3.txt",
" v dir2",
" file4.txt",
" file5.txt",
],
"Initial state with one marked entry and different selection"
);
// Delete should operate on the selected entry (file1.txt)
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" file2.txt <== selected <== marked",
" file3.txt",
" v dir2",
" file4.txt",
" file5.txt",
],
"Should delete selected file, not marked file"
);
select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
select_path(&panel, "root/dir2/file5.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" file2.txt <== marked",
" file3.txt <== marked",
" v dir2",
" file4.txt <== marked",
" file5.txt <== selected",
],
"Initial state with multiple marked entries and different selection"
);
// Delete should operate on all marked entries, ignoring the selection
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..15, cx),
&[
"v root",
" v dir1",
" v dir2",
" file5.txt <== selected",
],
"Should delete all marked files, leaving only the selected file"
);
}
#[gpui::test]
async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root_b",
json!({
"dir1": {
"file1.txt": "content 1",
"file2.txt": "content 2",
},
}),
)
.await;
fs.insert_tree(
"/root_c",
json!({
"dir2": {},
}),
)
.await;
let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root_b/dir1", cx);
toggle_expand_dir(&panel, "root_c/dir2", cx);
cx.simulate_modifiers_change(gpui::Modifiers {
control: true,
..Default::default()
});
select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root_b",
" v dir1",
" file1.txt <== marked",
" file2.txt <== selected <== marked",
"v root_c",
" v dir2",
],
"Initial state with files marked in root_b"
);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
"v root_b",
" v dir1 <== selected",
"v root_c",
" v dir2",
],
"After deletion in root_b as it's last deletion, selection should be in root_b"
);
select_path_with_mark(&panel, "root_c/dir2", cx);
submit_deletion(&panel, cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root_b", " v dir1", "v root_c <== selected",],
"After deleting from root_c, it should remain in root_c"
);
}
fn toggle_expand_dir(
panel: &Entity<ProjectPanel>,
path: impl AsRef<Path>,
cx: &mut VisualTestContext,
) {
let path = path.as_ref();
panel.update_in(cx, |panel, window, cx| {
for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
let worktree = worktree.read(cx);
if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
panel.toggle_expanded(entry_id, window, cx);
return;
}
}
panic!("no worktree for path {:?}", path);
});
}
#[gpui::test]
async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
".gitignore": "**/ignored_dir\n**/ignored_nested",
"dir1": {
"empty1": {
"empty2": {
"empty3": {
"file.txt": ""
}
}
},
"subdir1": {
"file1.txt": "",
"file2.txt": "",
"ignored_nested": {
"ignored_file.txt": ""
}
},
"ignored_dir": {
"subdir": {
"deep_file.txt": ""
}
}
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Test 1: When auto-fold is enabled
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root", " > dir1", " .gitignore",],
"Initial state should show collapsed root structure"
);
toggle_expand_dir(&panel, "root/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" > empty1/empty2/empty3"),
separator!(" > ignored_dir"),
separator!(" > subdir1"),
separator!(" .gitignore"),
],
"Should show first level with auto-folded dirs and ignored dir visible"
);
let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.expand_all_for_entry(worktree.id(), entry_id, cx);
panel.update_visible_entries(None, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" v empty1"),
separator!(" v empty2"),
separator!(" v empty3"),
separator!(" file.txt"),
separator!(" > ignored_dir"),
separator!(" v subdir1"),
separator!(" > ignored_nested"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" .gitignore"),
],
"After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
);
// Test 2: When auto-fold is disabled
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: false,
..settings
},
cx,
);
});
panel.update_in(cx, |panel, window, cx| {
panel.collapse_all_entries(&CollapseAllEntries, window, cx);
});
toggle_expand_dir(&panel, "root/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" > empty1"),
separator!(" > ignored_dir"),
separator!(" > subdir1"),
separator!(" .gitignore"),
],
"With auto-fold disabled: should show all directories separately"
);
let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.expand_all_for_entry(worktree.id(), entry_id, cx);
panel.update_visible_entries(None, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" v empty1"),
separator!(" v empty2"),
separator!(" v empty3"),
separator!(" file.txt"),
separator!(" > ignored_dir"),
separator!(" v subdir1"),
separator!(" > ignored_nested"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" .gitignore"),
],
"After expand_all without auto-fold: should expand all dirs normally, \
expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
);
// Test 3: When explicitly called on ignored directory
let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
panel.update_visible_entries(None, cx);
});
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" v empty1"),
separator!(" v empty2"),
separator!(" v empty3"),
separator!(" file.txt"),
separator!(" v ignored_dir"),
separator!(" v subdir"),
separator!(" deep_file.txt"),
separator!(" v subdir1"),
separator!(" > ignored_nested"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" .gitignore"),
],
"After expand_all on ignored_dir: should expand all contents of the ignored directory"
);
}
#[gpui::test]
async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"dir1": {
"subdir1": {
"nested1": {
"file1.txt": "",
"file2.txt": ""
},
},
"subdir2": {
"file4.txt": ""
}
},
"dir2": {
"single_file": {
"file5.txt": ""
}
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
// Test 1: Basic collapsing
{
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1"),
separator!(" v subdir1"),
separator!(" v nested1"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" v subdir2 <== selected"),
separator!(" file4.txt"),
separator!(" > dir2"),
],
"Initial state with everything expanded"
);
let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
panel.update_visible_entries(None, cx);
});
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&["v root", " > dir1", " > dir2",],
"All subdirs under dir1 should be collapsed"
);
}
// Test 2: With auto-fold enabled
{
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1"),
separator!(" v subdir1/nested1 <== selected"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" > subdir2"),
separator!(" > dir2/single_file"),
],
"Initial state with some dirs expanded"
);
let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
});
toggle_expand_dir(&panel, "root/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" > subdir1/nested1"),
separator!(" > subdir2"),
separator!(" > dir2/single_file"),
],
"Subdirs should be collapsed and folded with auto-fold enabled"
);
}
// Test 3: With auto-fold disabled
{
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: false,
..settings
},
cx,
);
});
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
toggle_expand_dir(&panel, "root/dir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1"),
separator!(" v subdir1"),
separator!(" v nested1 <== selected"),
separator!(" file1.txt"),
separator!(" file2.txt"),
separator!(" > subdir2"),
separator!(" > dir2"),
],
"Initial state with some dirs expanded and auto-fold disabled"
);
let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
panel.update(cx, |panel, cx| {
let project = panel.project.read(cx);
let worktree = project.worktrees(cx).next().unwrap().read(cx);
panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
});
toggle_expand_dir(&panel, "root/dir1", cx);
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" v dir1 <== selected"),
separator!(" > subdir1"),
separator!(" > subdir2"),
separator!(" > dir2"),
],
"Subdirs should be collapsed but not folded with auto-fold disabled"
);
}
}
#[gpui::test]
async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
path!("/root"),
json!({
"dir1": {
"file1.txt": "",
},
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace
.update(cx, |workspace, window, cx| {
let panel = ProjectPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
})
.unwrap();
#[rustfmt::skip]
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" > dir1"),
],
"Initial state with nothing selected"
);
panel.update_in(cx, |panel, window, cx| {
panel.new_file(&NewFile, window, cx);
});
panel.update_in(cx, |panel, window, cx| {
assert!(panel.filename_editor.read(cx).is_focused(window));
});
panel
.update_in(cx, |panel, window, cx| {
panel.filename_editor.update(cx, |editor, cx| {
editor.set_text("hello_from_no_selections", window, cx)
});
panel.confirm_edit(window, cx).unwrap()
})
.await
.unwrap();
#[rustfmt::skip]
assert_eq!(
visible_entries_as_strings(&panel, 0..20, cx),
&[
separator!("v root"),
separator!(" > dir1"),
separator!(" hello_from_no_selections <== selected <== marked"),
],
"A new file is created under the root directory"
);
}
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
let worktree = worktree.read(cx);
if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
panel.selection = Some(crate::SelectedEntry {
worktree_id: worktree.id(),
entry_id,
});
return;
}
}
panic!("no worktree for path {:?}", path);
});
}
fn select_path_with_mark(
panel: &Entity<ProjectPanel>,
path: impl AsRef<Path>,
cx: &mut VisualTestContext,
) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
let worktree = worktree.read(cx);
if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
let entry = crate::SelectedEntry {
worktree_id: worktree.id(),
entry_id,
};
if !panel.marked_entries.contains(&entry) {
panel.marked_entries.insert(entry);
}
panel.selection = Some(entry);
return;
}
}
panic!("no worktree for path {:?}", path);
});
}
fn find_project_entry(
panel: &Entity<ProjectPanel>,
path: impl AsRef<Path>,
cx: &mut VisualTestContext,
) -> Option<ProjectEntryId> {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
let worktree = worktree.read(cx);
if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
return worktree.entry_for_path(relative_path).map(|entry| entry.id);
}
}
panic!("no worktree for path {path:?}");
})
}
fn visible_entries_as_strings(
panel: &Entity<ProjectPanel>,
range: Range<usize>,
cx: &mut VisualTestContext,
) -> Vec<String> {
let mut result = Vec::new();
let mut project_entries = HashSet::default();
let mut has_editor = false;
panel.update_in(cx, |panel, window, cx| {
panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
if details.is_editing {
assert!(!has_editor, "duplicate editor entry");
has_editor = true;
} else {
assert!(
project_entries.insert(project_entry),
"duplicate project entry {:?} {:?}",
project_entry,
details
);
}
let indent = " ".repeat(details.depth);
let icon = if details.kind.is_dir() {
if details.is_expanded { "v " } else { "> " }
} else {
" "
};
let name = if details.is_editing {
format!("[EDITOR: '{}']", details.filename)
} else if details.is_processing {
format!("[PROCESSING: '{}']", details.filename)
} else {
details.filename.clone()
};
let selected = if details.is_selected {
" <== selected"
} else {
""
};
let marked = if details.is_marked {
" <== marked"
} else {
""
};
result.push(format!("{indent}{icon}{name}{selected}{marked}"));
});
});
result
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
init_settings(cx);
theme::init(theme::LoadThemes::JustBase, cx);
language::init(cx);
editor::init_settings(cx);
crate::init(cx);
workspace::init_settings(cx);
client::init_settings(cx);
Project::init_settings(cx);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_fold_dirs = Some(false);
});
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions = Some(Vec::new());
});
});
});
}
fn init_test_with_editor(cx: &mut TestAppContext) {
cx.update(|cx| {
let app_state = AppState::test(cx);
theme::init(theme::LoadThemes::JustBase, cx);
init_settings(cx);
language::init(cx);
editor::init(cx);
crate::init(cx);
workspace::init(app_state.clone(), cx);
Project::init_settings(cx);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
project_panel_settings.auto_fold_dirs = Some(false);
});
store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
worktree_settings.file_scan_exclusions = Some(Vec::new());
});
});
});
}
fn ensure_single_file_is_opened(
window: &WindowHandle<Workspace>,
expected_path: &str,
cx: &mut TestAppContext,
) {
window
.update(cx, |workspace, _, cx| {
let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
assert_eq!(worktrees.len(), 1);
let worktree_id = worktrees[0].read(cx).id();
let open_project_paths = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
.collect::<Vec<_>>();
assert_eq!(
open_project_paths,
vec![ProjectPath {
worktree_id,
path: Arc::from(Path::new(expected_path))
}],
"Should have opened file, selected in project panel"
);
})
.unwrap();
}
fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
assert!(
!cx.has_pending_prompt(),
"Should have no prompts before the deletion"
);
panel.update_in(cx, |panel, window, cx| {
panel.delete(&Delete { skip_prompt: false }, window, cx)
});
assert!(
cx.has_pending_prompt(),
"Should have a prompt after the deletion"
);
cx.simulate_prompt_answer("Delete");
assert!(
!cx.has_pending_prompt(),
"Should have no prompts after prompt was replied to"
);
cx.executor().run_until_parked();
}
fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
assert!(
!cx.has_pending_prompt(),
"Should have no prompts before the deletion"
);
panel.update_in(cx, |panel, window, cx| {
panel.delete(&Delete { skip_prompt: true }, window, cx)
});
assert!(!cx.has_pending_prompt(), "Should have received no prompts");
cx.executor().run_until_parked();
}
fn ensure_no_open_items_and_panes(workspace: &WindowHandle<Workspace>, cx: &mut VisualTestContext) {
assert!(
!cx.has_pending_prompt(),
"Should have no prompts after deletion operation closes the file"
);
workspace
.read_with(cx, |workspace, cx| {
let open_project_paths = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
.collect::<Vec<_>>();
assert!(
open_project_paths.is_empty(),
"Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
);
})
.unwrap();
}
struct TestProjectItemView {
focus_handle: FocusHandle,
path: ProjectPath,
}
struct TestProjectItem {
path: ProjectPath,
}
impl project::ProjectItem for TestProjectItem {
fn try_open(
_project: &Entity<Project>,
path: &ProjectPath,
cx: &mut App,
) -> Option<Task<gpui::Result<Entity<Self>>>> {
let path = path.clone();
Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
}
fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
None
}
fn project_path(&self, _: &App) -> Option<ProjectPath> {
Some(self.path.clone())
}
fn is_dirty(&self) -> bool {
false
}
}
impl ProjectItem for TestProjectItemView {
type Item = TestProjectItem;
fn for_project_item(
_: Entity<Project>,
_: Option<&Pane>,
project_item: Entity<Self::Item>,
_: &mut Window,
cx: &mut Context<Self>,
) -> Self
where
Self: Sized,
{
Self {
path: project_item.update(cx, |project_item, _| project_item.path.clone()),
focus_handle: cx.focus_handle(),
}
}
}
impl Item for TestProjectItemView {
type Event = ();
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Test".into()
}
}
impl EventEmitter<()> for TestProjectItemView {}
impl Focusable for TestProjectItemView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TestProjectItemView {
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}