983 lines
34 KiB
Rust
983 lines
34 KiB
Rust
use super::*;
|
|
|
|
#[test]
|
|
fn virtual_catalog_compacts_and_retains_file_directory_collisions() {
|
|
let mut picker = FilePicker::from_paths(
|
|
PathBuf::new(),
|
|
["x", "x/y", "src/deep/deleted.rs", "src/deep/deleted.rs"]
|
|
.map(PathBuf::from)
|
|
.into_iter()
|
|
.enumerate(),
|
|
);
|
|
assert_eq!(
|
|
picker
|
|
.entries()
|
|
.map(|row| row.label.to_owned())
|
|
.collect::<Vec<_>>(),
|
|
["src/deep", "x", "x"]
|
|
);
|
|
picker.reveal_path(Path::new("x"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
|
|
assert_eq!(picker.accept(), Some(PathBuf::from("x")));
|
|
picker.reveal_path(Path::new("x/y"));
|
|
assert_eq!(picker.accept(), Some(PathBuf::from("x/y")));
|
|
picker.reveal_path(Path::new("src/deep/deleted.rs"));
|
|
assert_eq!(picker.accept(), Some(PathBuf::from("src/deep/deleted.rs")));
|
|
assert_eq!(picker.entries().filter(|row| !row.is_dir).count(), 3);
|
|
let expected_index = |row: &FileRow<'_>| {
|
|
if row.is_dir {
|
|
None
|
|
} else {
|
|
Some(match row.path.to_str().unwrap() {
|
|
"x" => 0,
|
|
"x/y" => 1,
|
|
"src/deep/deleted.rs" => 2,
|
|
path => panic!("unexpected path {path}"),
|
|
})
|
|
}
|
|
};
|
|
for view in [FileView::List, FileView::Tree] {
|
|
picker.set_view(view);
|
|
for query in ["", "x", "deleted"] {
|
|
picker.query = query.into();
|
|
picker.refilter();
|
|
let rows: Vec<_> = picker.entries().collect();
|
|
assert!(!rows.is_empty());
|
|
for row in rows {
|
|
assert_eq!(row.catalog_index, expected_index(&row));
|
|
assert!(row.status.is_none() && row.old_path.is_none());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
|
struct Directory(PathBuf);
|
|
impl Directory {
|
|
fn new() -> Self {
|
|
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
|
let path =
|
|
std::env::temp_dir().join(format!("vamp-files-test-{}-{id}", std::process::id()));
|
|
fs::create_dir(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
fn put(&self, name: &str, text: &str) {
|
|
let path = self.0.join(name);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, text).unwrap();
|
|
}
|
|
fn scan(&self) -> FilePicker {
|
|
FilePicker::scan(self.0.clone(), FilePresentation::Floating, FileView::List).unwrap()
|
|
}
|
|
}
|
|
impl Drop for Directory {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
fn paths(picker: &FilePicker) -> Vec<PathBuf> {
|
|
picker
|
|
.entries()
|
|
.map(|entry| entry.path.to_owned())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn fuzzy_path_tokens_preserve_tree_context_selection_and_open_paths() {
|
|
let directory = Directory::new();
|
|
let names = [
|
|
"src/fuzzy_parser.rs",
|
|
"src/über_parser.rs",
|
|
"src/other.rs",
|
|
"tests/fuzzy_parser.rs",
|
|
];
|
|
for name in names {
|
|
directory.put(name, "");
|
|
}
|
|
for mut picker in [
|
|
directory.scan(),
|
|
FilePicker::from_paths(
|
|
directory.0.clone(),
|
|
names.map(PathBuf::from).into_iter().enumerate(),
|
|
),
|
|
] {
|
|
for view in [FileView::Tree, FileView::List] {
|
|
picker.set_view(view);
|
|
for (query, expected) in [
|
|
("sr/fp RS", "src/fuzzy_parser.rs"),
|
|
("ÜPR SRC", "src/über_parser.rs"),
|
|
] {
|
|
picker.query = query.into();
|
|
picker.refilter();
|
|
assert_eq!(
|
|
picker
|
|
.entries()
|
|
.filter(|row| !row.is_dir)
|
|
.map(|row| row.path)
|
|
.collect::<Vec<_>>(),
|
|
[Path::new(expected)]
|
|
);
|
|
assert_eq!(selected_path(&picker), Path::new(expected));
|
|
assert_eq!(picker.accept(), Some(directory.0.join(expected)));
|
|
if view == FileView::Tree {
|
|
assert_eq!(paths(&picker), [Path::new("src"), Path::new(expected)]);
|
|
}
|
|
}
|
|
picker.query = "rs/ü".into();
|
|
picker.refilter();
|
|
assert!(picker.entries().next().is_none());
|
|
picker.clear_filter();
|
|
assert_eq!(selected_path(&picker), Path::new("src/über_parser.rs"));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scanned_files_and_directories_have_no_catalog_index() {
|
|
let directory = Directory::new();
|
|
directory.put("nested/file.txt", "");
|
|
let mut picker = directory.scan();
|
|
for view in [FileView::List, FileView::Tree] {
|
|
picker.set_view(view);
|
|
picker.reveal_path(Path::new("nested/file.txt"));
|
|
assert!(picker.entries().all(|row| row.catalog_index.is_none()));
|
|
assert!(
|
|
picker
|
|
.entries()
|
|
.all(|row| row.status.is_none() && row.old_path.is_none())
|
|
);
|
|
}
|
|
}
|
|
|
|
fn repository_change(path: &str, status: FileStatus, old_path: Option<&str>) -> WorkingFileChange {
|
|
WorkingFileChange {
|
|
path: path.into(),
|
|
status,
|
|
old_path: old_path.map(PathBuf::from),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn repository_metadata_keeps_clean_files_selection_and_filtered_views() {
|
|
let directory = Directory::new();
|
|
for name in ["src/clean.rs", "src/modified.rs", "added.rs", "renamed.rs"] {
|
|
directory.put(name, "");
|
|
}
|
|
let mut picker = directory.scan();
|
|
picker.presentation = FilePresentation::Docked;
|
|
picker.set_view(FileView::Tree);
|
|
picker.reveal_path(Path::new("src/clean.rs"));
|
|
picker.query = "src".into();
|
|
picker.refilter();
|
|
picker.focused = false;
|
|
let changes = vec![
|
|
repository_change("src/modified.rs", FileStatus::Modified, None),
|
|
repository_change("added.rs", FileStatus::Added, None),
|
|
repository_change("renamed.rs", FileStatus::Renamed, Some("old.rs")),
|
|
repository_change("vanished/deep/deleted.rs", FileStatus::Removed, None),
|
|
];
|
|
assert!(picker.set_repository_changes(changes.clone()));
|
|
assert!(!picker.set_repository_changes(changes));
|
|
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
|
assert_eq!(picker.query, "src");
|
|
assert!(!picker.focused);
|
|
assert_eq!(picker.presentation, FilePresentation::Docked);
|
|
for view in [FileView::List, FileView::Tree] {
|
|
picker.set_view(view);
|
|
for query in ["", "src", "modified.rs", "deleted.rs"] {
|
|
picker.query = query.into();
|
|
picker.refilter();
|
|
let rows: Vec<_> = picker.entries().collect();
|
|
assert!(!rows.is_empty());
|
|
for row in rows {
|
|
assert!(row.catalog_index.is_none());
|
|
if row.is_dir || row.path == Path::new("src/clean.rs") {
|
|
assert!(row.status.is_none() && row.old_path.is_none());
|
|
} else {
|
|
let status = match row.path.to_str().unwrap() {
|
|
"src/modified.rs" => FileStatus::Modified,
|
|
"added.rs" => FileStatus::Added,
|
|
"renamed.rs" => FileStatus::Renamed,
|
|
"vanished/deep/deleted.rs" => FileStatus::Removed,
|
|
other => panic!("unexpected {other}"),
|
|
};
|
|
assert_eq!(row.status, Some(status));
|
|
assert_eq!(
|
|
row.old_path,
|
|
(status == FileStatus::Renamed).then(|| Path::new("old.rs"))
|
|
);
|
|
}
|
|
}
|
|
if query.is_empty() || query == "src" {
|
|
assert!(
|
|
picker
|
|
.entries()
|
|
.any(|row| row.path == Path::new("src/clean.rs"))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
picker.set_view(FileView::List);
|
|
picker.clear_filter();
|
|
picker.reveal_path(Path::new("src/clean.rs"));
|
|
assert!(picker.set_repository_changes(Vec::new()));
|
|
assert_eq!(selected_path(&picker), Path::new("src/clean.rs"));
|
|
assert_eq!(picker.entries().len(), 4);
|
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
assert!(
|
|
picker
|
|
.entries()
|
|
.all(|row| row.status.is_none() && row.old_path.is_none())
|
|
);
|
|
assert!(!picker.set_repository_changes(Vec::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_catalog_leaves_survive_refresh_and_restored_files_survive_clear() {
|
|
let directory = Directory::new();
|
|
directory.put("keep.txt", "");
|
|
let mut picker = directory.scan();
|
|
let deleted = Path::new("lost/deep/deleted.txt");
|
|
picker.set_repository_changes(vec![repository_change(
|
|
"lost/deep/deleted.txt",
|
|
FileStatus::Removed,
|
|
None,
|
|
)]);
|
|
picker.set_view(FileView::Tree);
|
|
assert_eq!(
|
|
picker.entries().find(|row| row.is_dir).unwrap().label,
|
|
"lost/deep"
|
|
);
|
|
picker.reveal_path(deleted);
|
|
picker.query = "deleted".into();
|
|
picker.refilter();
|
|
picker.start_search();
|
|
picker.focused = false;
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), deleted);
|
|
assert!(picker.searching && !picker.focused);
|
|
assert_eq!(picker.query, "deleted");
|
|
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
|
assert!(!directory.0.join(deleted).exists());
|
|
assert_eq!(
|
|
picker.entries().find(|row| !row.is_dir).unwrap().status,
|
|
Some(FileStatus::Removed)
|
|
);
|
|
directory.put("lost/deep/deleted.txt", "restored");
|
|
assert!(picker.set_repository_changes(vec![repository_change(
|
|
"lost/deep/deleted.txt",
|
|
FileStatus::Removed,
|
|
None,
|
|
)]));
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), deleted);
|
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
assert!(picker.set_repository_changes(Vec::new()));
|
|
assert_eq!(selected_path(&picker), deleted);
|
|
assert_eq!(picker.accept(), Some(directory.0.join(deleted)));
|
|
assert_eq!(
|
|
fs::read_to_string(directory.0.join(deleted)).unwrap(),
|
|
"restored"
|
|
);
|
|
assert!(picker.entries().all(|row| row.status.is_none()));
|
|
fs::remove_dir_all(directory.0.join("lost")).unwrap();
|
|
picker.refresh();
|
|
picker.set_repository_changes(vec![repository_change(
|
|
"lost/deep/deleted.txt",
|
|
FileStatus::Removed,
|
|
None,
|
|
)]);
|
|
picker.reveal_path(deleted);
|
|
picker.set_repository_changes(Vec::new());
|
|
assert!(picker.entries().len() == 0);
|
|
picker.clear_filter();
|
|
assert_eq!(paths(&picker), [PathBuf::from("keep.txt")]);
|
|
assert!(picker.nodes.iter().all(|node| !node.virtual_entry));
|
|
}
|
|
|
|
#[test]
|
|
fn repository_metadata_cannot_introduce_unsafe_or_existing_excluded_paths() {
|
|
let directory = Directory::new();
|
|
directory.put(".gitignore", "ignored/\n");
|
|
directory.put("ignored/hidden.txt", "");
|
|
directory.put("directory/keep.txt", "");
|
|
let mut picker = directory.scan();
|
|
let mut changes: Vec<_> = [
|
|
"",
|
|
".",
|
|
"../outside.txt",
|
|
"directory/../../outside.txt",
|
|
".git/secret",
|
|
"nested/.jj/secret",
|
|
"/outside.txt",
|
|
"directory",
|
|
"ignored/hidden.txt",
|
|
]
|
|
.into_iter()
|
|
.map(|path| repository_change(path, FileStatus::Removed, None))
|
|
.collect();
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(&directory.0, directory.0.join("link")).unwrap();
|
|
changes.push(repository_change(
|
|
"link/missing.txt",
|
|
FileStatus::Removed,
|
|
None,
|
|
));
|
|
}
|
|
changes.push(repository_change("missing.txt", FileStatus::Removed, None));
|
|
picker.set_repository_changes(changes);
|
|
assert_eq!(
|
|
paths(&picker),
|
|
[".gitignore", "directory/keep.txt", "missing.txt"].map(PathBuf::from)
|
|
);
|
|
assert_eq!(
|
|
picker.entries().filter(|row| row.status.is_some()).count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
picker.entries().last().unwrap().status,
|
|
Some(FileStatus::Removed)
|
|
);
|
|
assert!(!directory.0.join("missing.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn nested_ignore_negation_hidden_files_and_vcs_metadata() {
|
|
let directory = Directory::new();
|
|
directory.put(".gitignore", "*.log\nignored/\n");
|
|
directory.put("nested/.gitignore", "!keep.log\nlocal.txt\n");
|
|
for name in [
|
|
"gone.log",
|
|
"nested/gone.log",
|
|
"nested/keep.log",
|
|
"nested/local.txt",
|
|
"nested/ok.txt",
|
|
"ignored/hidden.txt",
|
|
".hidden",
|
|
".git/objects/secret",
|
|
"nested/.git/secret",
|
|
".jj/secret",
|
|
"nested/.jj/secret",
|
|
] {
|
|
directory.put(name, "text");
|
|
}
|
|
let picker = directory.scan();
|
|
assert_eq!(
|
|
paths(&picker),
|
|
[
|
|
".gitignore",
|
|
".hidden",
|
|
"nested/.gitignore",
|
|
"nested/keep.log",
|
|
"nested/ok.txt"
|
|
]
|
|
.map(PathBuf::from)
|
|
);
|
|
assert!(picker.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn literal_unicode_tokens_grapheme_deletion_and_navigation() {
|
|
let directory = Directory::new();
|
|
for name in ["a space 界[e\u{301}].txt", "b.txt", "c.txt"] {
|
|
directory.put(name, "text");
|
|
}
|
|
let mut picker = directory.scan();
|
|
picker.navigate(false);
|
|
assert_eq!(picker.selected, 2);
|
|
picker.navigate(true);
|
|
assert_eq!(picker.selected, 0);
|
|
picker.query = "界 SPACE [E\u{301}]".into();
|
|
picker.refilter();
|
|
assert_eq!(paths(&picker), [PathBuf::from("a space 界[e\u{301}].txt")]);
|
|
picker.query = "e\u{301}👩💻".into();
|
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
assert_eq!(picker.query, "e\u{301}");
|
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
assert_eq!(picker.query, "");
|
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
assert_eq!(picker.entries().len(), 3);
|
|
picker.query = "no match".into();
|
|
picker.refilter();
|
|
for forward in [true, false] {
|
|
picker.navigate(forward);
|
|
assert_eq!(picker.selected, 0);
|
|
assert_eq!(picker.entries().len(), 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn word_and_line_deletion_refilter_only_editable_queries() {
|
|
use crate::editor::Input;
|
|
|
|
for view in [FileView::List, FileView::Tree] {
|
|
let mut picker = FilePicker::from_paths(
|
|
PathBuf::new(),
|
|
["alpha one.txt", "alpha two.txt", "beta.txt"]
|
|
.map(PathBuf::from)
|
|
.into_iter()
|
|
.enumerate(),
|
|
);
|
|
picker.set_view(view);
|
|
picker.query = "alpha missing".into();
|
|
picker.refilter();
|
|
assert_eq!(picker.entries().len(), 0);
|
|
if view == FileView::Tree {
|
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
|
assert!(!picker.handle_filter_input(&input));
|
|
assert_eq!(picker.query, "alpha missing");
|
|
}
|
|
picker.start_search();
|
|
}
|
|
assert!(picker.handle_filter_input(&Input::AltBackspace));
|
|
assert_eq!(picker.query, "alpha ");
|
|
assert_eq!(picker.entries().len(), 2);
|
|
assert!(picker.handle_filter_input(&Input::SuperBackspace));
|
|
assert_eq!(picker.query, "");
|
|
assert_eq!(picker.entries().len(), 3);
|
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
|
assert!(picker.handle_filter_input(&input));
|
|
assert_eq!(picker.entries().len(), 3);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_scan_refresh_and_failure_preserve_reachable_state() {
|
|
let directory = Directory::new();
|
|
let mut picker = directory.scan();
|
|
picker.navigate(false);
|
|
assert!(picker.handle_filter_input(&crate::editor::Input::Backspace));
|
|
assert_eq!(picker.entries().len(), 0);
|
|
directory.put("new.txt", "text");
|
|
picker.query = "new".into();
|
|
picker.refresh();
|
|
assert_eq!(picker.query, "new");
|
|
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
|
fs::remove_dir_all(&directory.0).unwrap();
|
|
picker.refresh();
|
|
assert_eq!(paths(&picker), [PathBuf::from("new.txt")]);
|
|
assert!(picker.error.as_ref().unwrap().contains("Refresh failed"));
|
|
assert!(
|
|
FilePicker::scan(
|
|
directory.0.clone(),
|
|
FilePresentation::Docked,
|
|
FileView::Tree
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_ignore_warning_survives_query_and_navigation() {
|
|
let directory = Directory::new();
|
|
directory.put(".gitignore", "[z-a]\n");
|
|
directory.put("ok.txt", "text");
|
|
let mut picker = directory.scan();
|
|
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
|
picker.query = "ok".into();
|
|
picker.refilter();
|
|
assert_eq!(paths(&picker), [PathBuf::from("ok.txt")]);
|
|
picker.navigate(true);
|
|
assert!(picker.error.as_ref().unwrap().contains("Incomplete scan"));
|
|
directory.put(".gitignore", "");
|
|
picker.refresh();
|
|
assert!(picker.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn only_project_ignore_rules_apply_and_non_directory_roots_fail() {
|
|
let directory = Directory::new();
|
|
directory.put(".gitignore", "*.txt\n");
|
|
directory.put("project/visible.txt", "text");
|
|
let root = directory.0.join("project");
|
|
let picker = FilePicker::scan(root, FilePresentation::Floating, FileView::List).unwrap();
|
|
assert_eq!(paths(&picker), [PathBuf::from("visible.txt")]);
|
|
assert!(
|
|
FilePicker::scan(
|
|
directory.0.join("project/visible.txt"),
|
|
FilePresentation::Docked,
|
|
FileView::Tree,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn symlink_loops_and_file_links_are_not_followed_and_raw_paths_survive() {
|
|
use std::os::unix::fs::symlink;
|
|
let directory = Directory::new();
|
|
directory.put("real.txt", "text");
|
|
symlink(&directory.0, directory.0.join("loop")).unwrap();
|
|
symlink(directory.0.join("real.txt"), directory.0.join("alias.txt")).unwrap();
|
|
let raw = PathBuf::from("raw界\n.txt");
|
|
fs::write(directory.0.join(&raw), "text").unwrap();
|
|
let picker = directory.scan();
|
|
assert_eq!(picker.entries().len(), 2);
|
|
let entry = picker
|
|
.entries()
|
|
.find(|entry| entry.path == raw.as_path())
|
|
.unwrap();
|
|
assert!(!entry.label.chars().any(char::is_control));
|
|
assert_eq!(
|
|
fs::read_to_string(directory.0.join(entry.path)).unwrap(),
|
|
"text"
|
|
);
|
|
}
|
|
|
|
fn tree(directory: &Directory) -> FilePicker {
|
|
FilePicker::scan(
|
|
directory.0.clone(),
|
|
FilePresentation::Docked,
|
|
FileView::Tree,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn select(picker: &mut FilePicker, path: &str) {
|
|
let selected = picker
|
|
.entries()
|
|
.position(|row| row.path == Path::new(path))
|
|
.unwrap();
|
|
picker.selected = selected;
|
|
}
|
|
|
|
fn selected_path(picker: &FilePicker) -> &Path {
|
|
picker.entries().nth(picker.selected).unwrap().path
|
|
}
|
|
|
|
#[test]
|
|
fn tree_orders_folders_first_keeps_empty_dirs_and_compacts_only_directory_chains() {
|
|
let directory = Directory::new();
|
|
directory.put("a.txt", "");
|
|
directory.put("folder/only.txt", "");
|
|
directory.put("chain/one/two/a.txt", "");
|
|
directory.put("chain/one/two/z.txt", "");
|
|
fs::create_dir(directory.0.join("empty")).unwrap();
|
|
let mut picker = tree(&directory);
|
|
assert_eq!(picker.view, FileView::Tree);
|
|
assert!(!picker.searching);
|
|
assert_eq!(
|
|
picker
|
|
.entries()
|
|
.map(|row| (row.label, row.is_dir, row.depth))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
("chain/one/two", true, 0),
|
|
("empty", true, 0),
|
|
("folder", true, 0),
|
|
("a.txt", false, 0)
|
|
]
|
|
);
|
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
|
picker.right();
|
|
assert_eq!(
|
|
picker
|
|
.entries()
|
|
.map(|row| (row.label, row.depth))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
("chain/one/two", 0),
|
|
("a.txt", 1),
|
|
("z.txt", 1),
|
|
("empty", 0),
|
|
("folder", 0),
|
|
("a.txt", 0)
|
|
]
|
|
);
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two/a.txt"));
|
|
let rows = paths(&picker);
|
|
picker.right();
|
|
assert_eq!(paths(&picker), rows);
|
|
picker.left();
|
|
assert_eq!(selected_path(&picker), Path::new("chain/one/two"));
|
|
picker.left();
|
|
assert!(!picker.entries().next().unwrap().expanded);
|
|
picker.left();
|
|
assert_eq!(picker.selected, 0);
|
|
select(&mut picker, "folder");
|
|
assert!(picker.accept().is_none());
|
|
assert_eq!(
|
|
picker.entries().nth(picker.selected + 1).unwrap().label,
|
|
"only.txt"
|
|
);
|
|
picker.right();
|
|
assert_eq!(picker.accept(), Some(directory.0.join("folder/only.txt")));
|
|
select(&mut picker, "empty");
|
|
picker.right();
|
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("empty"));
|
|
}
|
|
|
|
#[test]
|
|
fn tree_search_reveals_ancestors_without_mutating_saved_expansion_or_compaction() {
|
|
let directory = Directory::new();
|
|
directory.put("src/module/deep/needle.txt", "");
|
|
directory.put("src/module/sibling.txt", "");
|
|
directory.put("src/root.txt", "");
|
|
directory.put("other/keep.txt", "");
|
|
let mut picker = tree(&directory);
|
|
select(&mut picker, "other");
|
|
picker.right();
|
|
select(&mut picker, "src");
|
|
let expanded = picker.expanded.clone();
|
|
picker.start_search();
|
|
picker.query = "NEEDLE".into();
|
|
picker.refilter();
|
|
assert_eq!(
|
|
picker
|
|
.entries()
|
|
.map(|row| (row.label, row.depth))
|
|
.collect::<Vec<_>>(),
|
|
[("src", 0), ("module", 1), ("deep", 2), ("needle.txt", 3)]
|
|
);
|
|
assert_eq!(
|
|
selected_path(&picker),
|
|
Path::new("src/module/deep/needle.txt")
|
|
);
|
|
assert_eq!(picker.expanded, expanded);
|
|
picker.left();
|
|
let filtered = paths(&picker);
|
|
picker.finish_search();
|
|
assert_eq!(picker.query, "NEEDLE");
|
|
assert_eq!(paths(&picker), filtered);
|
|
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
|
assert_eq!(picker.expanded, expanded);
|
|
picker.start_search();
|
|
picker.query = "deep".into();
|
|
picker.refilter();
|
|
select(&mut picker, "src/module/deep");
|
|
assert!(picker.accept().is_none());
|
|
assert!(!picker.searching);
|
|
assert_eq!(picker.query, "deep");
|
|
assert_eq!(selected_path(&picker), Path::new("src/module/deep"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
assert_eq!(picker.expanded, expanded);
|
|
picker.right();
|
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
assert_eq!(picker.query, "deep");
|
|
|
|
let chain = Directory::new();
|
|
chain.put("one/two/three/a.txt", "");
|
|
chain.put("one/two/three/b.txt", "");
|
|
let mut picker = tree(&chain);
|
|
picker.start_search();
|
|
picker.query = "a.txt".into();
|
|
picker.refilter();
|
|
assert_eq!(
|
|
picker.entries().map(|row| row.label).collect::<Vec<_>>(),
|
|
["one/two/three", "a.txt"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn view_switches_restore_directory_choices_and_reveal_explicit_file_choices() {
|
|
let directory = Directory::new();
|
|
directory.put("src/a.txt", "");
|
|
directory.put("src/b.txt", "");
|
|
directory.put("z.txt", "");
|
|
let mut picker = tree(&directory);
|
|
picker.right();
|
|
let open = paths(&picker);
|
|
picker.set_view(FileView::List);
|
|
assert_eq!(
|
|
paths(&picker),
|
|
["src/a.txt", "src/b.txt", "z.txt"].map(PathBuf::from)
|
|
);
|
|
picker.set_view(FileView::Tree);
|
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
assert_eq!(paths(&picker), open);
|
|
picker.left();
|
|
picker.toggle_view();
|
|
picker.navigate(true);
|
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
picker.toggle_view();
|
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
assert!(picker.entries().next().unwrap().expanded);
|
|
picker.left();
|
|
picker.toggle_view();
|
|
picker.toggle_view();
|
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
}
|
|
|
|
#[test]
|
|
fn filtering_and_refresh_preserve_raw_selection_and_surviving_expansion() {
|
|
let directory = Directory::new();
|
|
directory.put("src/a.txt", "");
|
|
directory.put("src/b.txt", "");
|
|
directory.put("z.txt", "");
|
|
let mut picker = directory.scan();
|
|
select(&mut picker, "src/b.txt");
|
|
picker.query = "absent".into();
|
|
picker.refilter();
|
|
assert_eq!(picker.entries().len(), 0);
|
|
picker.query.clear();
|
|
picker.refilter();
|
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
picker.set_view(FileView::Tree);
|
|
assert_eq!(selected_path(&picker), Path::new("src/b.txt"));
|
|
fs::remove_file(directory.0.join("src/b.txt")).unwrap();
|
|
directory.put("src/c.txt", "");
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("src"));
|
|
assert!(picker.entries().next().unwrap().expanded);
|
|
assert!(paths(&picker).contains(&PathBuf::from("src/c.txt")));
|
|
fs::remove_dir_all(directory.0.join("src")).unwrap();
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
|
assert!(picker.expanded.is_empty());
|
|
picker.toggle_view();
|
|
picker.toggle_view();
|
|
assert_eq!(selected_path(&picker), Path::new("z.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn tree_search_subfocus_and_expansion_survive_view_toggles_and_refresh() {
|
|
let directory = Directory::new();
|
|
directory.put("src/deep/a.txt", "");
|
|
directory.put("src/other.txt", "");
|
|
let mut picker = tree(&directory);
|
|
picker.start_search();
|
|
assert!(picker.searching);
|
|
assert_eq!(picker.entries().len(), 4);
|
|
assert!(picker.expanded.is_empty());
|
|
picker.toggle_view();
|
|
assert_eq!(picker.view, FileView::List);
|
|
picker.toggle_view();
|
|
assert!(picker.searching);
|
|
assert!(picker.expanded.is_empty());
|
|
picker.query = "a.txt".into();
|
|
picker.refilter();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
picker.refresh();
|
|
assert!(picker.searching);
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
let filtered = paths(&picker);
|
|
picker.finish_search();
|
|
assert!(!picker.searching);
|
|
assert_eq!(picker.query, "a.txt");
|
|
assert_eq!(paths(&picker), filtered);
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
assert!(picker.expanded.is_empty());
|
|
picker.toggle_view();
|
|
picker.refresh();
|
|
picker.toggle_view();
|
|
assert!(!picker.searching);
|
|
assert_eq!(picker.query, "a.txt");
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
picker.start_search();
|
|
assert!(picker.searching);
|
|
assert_eq!(picker.query, "a.txt");
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/a.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn clearing_filter_preserves_mode_focus_current_path_and_empty_result_fallback() {
|
|
let directory = Directory::new();
|
|
directory.put("src/deep/needle.txt", "");
|
|
directory.put("src/other.txt", "");
|
|
directory.put("other/keep.txt", "");
|
|
for (view, searching) in [
|
|
(FileView::Tree, false),
|
|
(FileView::Tree, true),
|
|
(FileView::List, false),
|
|
] {
|
|
let mut picker = tree(&directory);
|
|
select(&mut picker, "other");
|
|
picker.right();
|
|
let expanded = picker.expanded.clone();
|
|
picker.set_view(view);
|
|
if view == FileView::Tree {
|
|
picker.start_search();
|
|
}
|
|
picker.query = "needle".into();
|
|
picker.refilter();
|
|
if !searching && view == FileView::Tree {
|
|
picker.finish_search();
|
|
}
|
|
let expected = Path::new("src/deep/needle.txt");
|
|
assert_eq!(selected_path(&picker), expected);
|
|
picker.clear_filter();
|
|
assert!(picker.query.is_empty());
|
|
assert_eq!(picker.searching, searching);
|
|
assert!(picker.focused);
|
|
assert_eq!(selected_path(&picker), expected);
|
|
assert!(picker.expanded.is_superset(&expanded));
|
|
assert!(paths(&picker).contains(&PathBuf::from("other/keep.txt")));
|
|
let rows = paths(&picker);
|
|
let expanded = picker.expanded.clone();
|
|
picker.clear_filter();
|
|
assert_eq!(paths(&picker), rows);
|
|
assert_eq!(picker.expanded, expanded);
|
|
assert_eq!(selected_path(&picker), expected);
|
|
assert_eq!(picker.searching, searching);
|
|
|
|
picker.query = "absent".into();
|
|
picker.refilter();
|
|
picker.navigate(true);
|
|
picker.navigate(false);
|
|
assert!(picker.rows.is_empty());
|
|
picker.refresh();
|
|
assert!(picker.rows.is_empty());
|
|
picker.clear_filter();
|
|
assert_eq!(selected_path(&picker), expected);
|
|
assert_eq!(picker.searching, searching);
|
|
assert!(picker.focused);
|
|
}
|
|
|
|
let empty = Directory::new();
|
|
let mut picker = tree(&empty);
|
|
picker.start_search();
|
|
picker.query = "absent".into();
|
|
picker.refilter();
|
|
picker.finish_search();
|
|
picker.clear_filter();
|
|
assert!(picker.rows.is_empty());
|
|
assert_eq!(picker.selected, 0);
|
|
assert!(!picker.searching);
|
|
assert!(picker.focused);
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_navigation_toggles_context_folders_and_query_edits_reveal_matches() {
|
|
let directory = Directory::new();
|
|
directory.put("src/deep/needle.txt", "");
|
|
directory.put("src/other.txt", "");
|
|
let mut picker = tree(&directory);
|
|
picker.start_search();
|
|
picker.query = "needle".into();
|
|
picker.refilter();
|
|
let expanded = picker.expanded.clone();
|
|
picker.left();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
assert_eq!(picker.expanded, expanded);
|
|
picker.finish_search();
|
|
picker.left();
|
|
picker.left();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
assert_eq!(picker.query, "needle");
|
|
assert!(!paths(&picker).contains(&PathBuf::from("src/deep/needle.txt")));
|
|
picker.start_search();
|
|
picker.finish_search();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.right();
|
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
picker.left();
|
|
assert!(picker.accept().is_none());
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
assert!(picker.accept().is_none());
|
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.start_search();
|
|
assert!(picker.accept().is_none());
|
|
assert!(!picker.searching);
|
|
assert_eq!(picker.query, "needle");
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.start_search();
|
|
picker.query.push_str(".txt");
|
|
picker.refilter();
|
|
assert!(picker.filter_collapsed.is_empty());
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
picker.query.clear();
|
|
picker.refilter();
|
|
picker.finish_search();
|
|
assert_eq!(selected_path(&picker), Path::new("src/deep/needle.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_collapse_survives_refresh_compaction_and_view_switching_by_raw_path() {
|
|
let directory = Directory::new();
|
|
directory.put("one/a.txt", "");
|
|
directory.put("one/two/needle.txt", "");
|
|
let mut picker = tree(&directory);
|
|
picker.start_search();
|
|
picker.query = "needle".into();
|
|
picker.refilter();
|
|
picker.finish_search();
|
|
picker.left();
|
|
picker.left();
|
|
picker.left();
|
|
picker.left();
|
|
assert_eq!(selected_path(&picker), Path::new("one"));
|
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
picker.toggle_view();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
|
picker.toggle_view();
|
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
assert!(!picker.searching);
|
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
picker.focused = false;
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
|
assert!(!picker.searching && !picker.focused);
|
|
assert_eq!(picker.query, "needle");
|
|
directory.put("one/a.txt", "");
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("one"));
|
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
picker.right();
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.right();
|
|
assert!(picker.entries().nth(picker.selected).unwrap().expanded);
|
|
picker.right();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two/needle.txt"));
|
|
fs::remove_file(directory.0.join("one/two/needle.txt")).unwrap();
|
|
picker.refresh();
|
|
assert!(picker.entries().len() == 0);
|
|
picker.clear_filter();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
assert!(!picker.searching && !picker.focused);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_preserves_expansion_when_directory_chains_merge_and_split() {
|
|
let directory = Directory::new();
|
|
directory.put("one/a.txt", "");
|
|
directory.put("one/two/b.txt", "");
|
|
let mut picker = tree(&directory);
|
|
picker.right();
|
|
assert_eq!(
|
|
paths(&picker),
|
|
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
|
);
|
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
assert_eq!(
|
|
paths(&picker),
|
|
["one/two", "one/two/b.txt"].map(PathBuf::from)
|
|
);
|
|
assert!(picker.entries().next().unwrap().expanded);
|
|
directory.put("one/a.txt", "");
|
|
picker.refresh();
|
|
assert_eq!(selected_path(&picker), Path::new("one/two"));
|
|
assert_eq!(
|
|
paths(&picker),
|
|
["one", "one/two", "one/a.txt"].map(PathBuf::from)
|
|
);
|
|
assert!(picker.entries().next().unwrap().expanded);
|
|
assert!(!picker.entries().nth(1).unwrap().expanded);
|
|
fs::remove_file(directory.0.join("one/a.txt")).unwrap();
|
|
picker.refresh();
|
|
picker.left();
|
|
assert_eq!(paths(&picker), [PathBuf::from("one/two")]);
|
|
directory.put("one/a.txt", "");
|
|
picker.refresh();
|
|
assert_eq!(paths(&picker), [PathBuf::from("one")]);
|
|
assert!(!picker.entries().next().unwrap().expanded);
|
|
}
|