use super::*;
#[test]
fn copy_paths_and_names_from_a_panel() {
let dir = std::env::temp_dir().join(format!("fm84-copy-names-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "").unwrap();
std::fs::write(dir.join("b.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("b.txt".as_ref()));
let chord = |app_state: &mut AppState, modifiers| handle_key(app_state, KeyEvent::new(KeyCode::Char('c'), modifiers));
chord(&mut app_state, KeyModifiers::CONTROL);
assert_eq!(app_state.clipboard, dir.join("b.txt").to_string_lossy());
assert!(app_state.find_note.as_deref().unwrap().starts_with("Copied path: "));
chord(&mut app_state, KeyModifiers::ALT);
assert_eq!(app_state.clipboard, "b.txt");
app_state.selected_left.insert("a.txt".into());
app_state.selected_left.insert("b.txt".into());
chord(&mut app_state, KeyModifiers::ALT);
assert_eq!(app_state.clipboard, "a.txt\nb.txt");
assert_eq!(app_state.find_note.as_deref(), Some("Copied 2 names"));
press(&mut app_state, KeyCode::Down);
assert!(app_state.find_note.is_none());
app_state.selected_left.clear();
app_state.state_left.select(Some(0));
chord(&mut app_state, KeyModifiers::CONTROL);
assert_eq!(app_state.clipboard, dir.to_string_lossy());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn swap_the_panels_and_send_the_other_one_here() {
let dir = std::env::temp_dir().join(format!("fm84-swap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for name in ["one", "two", "three"] {
std::fs::create_dir_all(dir.join(name)).unwrap();
}
std::fs::write(dir.join("one").join("a.txt"), "").unwrap();
std::fs::write(dir.join("one").join("b.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.join("one"), Some("b.txt".as_ref()));
app_state.selected_left.insert("a.txt".into());
app_state.open_dir(false, dir.join("two"), None);
app_state.is_left_active = false;
app_state.new_tab();
app_state.open_dir(false, dir.join("three"), None);
app_state.is_left_active = true;
let chord = |app_state: &mut AppState, c, modifiers| handle_key(app_state, KeyEvent::new(KeyCode::Char(c), modifiers));
let cursor = |app_state: &AppState, is_left: bool| {
let (children, state) = if is_left { (&app_state.children_left, &app_state.state_left) } else { (&app_state.children_right, &app_state.state_right) };
children[state.selected().unwrap()].name_full.clone()
};
chord(&mut app_state, 'u', KeyModifiers::CONTROL);
assert_eq!((app_state.dir_left.clone(), app_state.dir_right.clone()), (dir.join("three"), dir.join("one")));
assert_eq!((app_state.tabs_left.list.len(), app_state.tabs_left.active, app_state.tabs_right.list.len()), (2, 1, 1));
assert!(!app_state.is_left_active);
assert_eq!(cursor(&app_state, false), "b.txt");
assert!(app_state.selected_right.contains(std::ffi::OsStr::new("a.txt")) && app_state.selected_left.is_empty());
app_state.select_tab(true, 0);
assert_eq!(app_state.dir_left, dir.join("two"));
chord(&mut app_state, 'i', KeyModifiers::ALT);
assert_eq!(app_state.dir_left, dir.join("one"));
assert_eq!(cursor(&app_state, true), "b.txt");
assert!(!app_state.is_left_active);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sort_from_the_keys_and_the_headings() {
use crate::options::{Options, SortKey};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let dir = std::env::temp_dir().join(format!("fm84-sort-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "a long one").unwrap();
std::fs::write(dir.join("b.rs"), "short").unwrap();
std::fs::write(dir.join("c.md"), "mid size").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("b.rs".as_ref()));
let names = |app_state: &AppState| app_state.children_left.iter().skip(1).map(|item| item.name_full.clone()).collect::<Vec<_>>();
let control = |app_state: &mut AppState, n| handle_key(app_state, KeyEvent::new(KeyCode::F(n), KeyModifiers::CONTROL));
control(&mut app_state, 3);
assert_eq!(names(&app_state), ["a.txt", "b.rs", "c.md"]);
control(&mut app_state, 3);
assert!(app_state.options.sort_descending);
assert_eq!(names(&app_state), ["c.md", "b.rs", "a.txt"]);
control(&mut app_state, 4);
assert_eq!((app_state.options.sort_key, app_state.options.sort_descending), (SortKey::Extension, false));
assert_eq!(names(&app_state), ["c.md", "b.rs", "a.txt"]);
let cursor = app_state.state_left.selected().unwrap();
assert_eq!(app_state.children_left[cursor].name_full, "b.rs");
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let (cell, _) = *app_state.sort_headers.iter().find(|(_, key)| *key == SortKey::Size).unwrap();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: cell.x,
row: cell.y,
modifiers: KeyModifiers::NONE,
};
crate::input::handle_mouse(&mut app_state, click);
assert_eq!(app_state.options.sort_key, SortKey::Size);
assert_eq!(names(&app_state), ["b.rs", "c.md", "a.txt"]);
crate::ui::render_ui(&mut terminal, &mut app_state);
let heading: String = (cell.x..cell.x + cell.width).map(|x| terminal.backend().buffer()[(x, cell.y)].symbol().to_string()).collect();
assert_eq!(heading.trim_end(), "Size ↑");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn go_to_a_path_and_back_and_forward() {
let dir = std::env::temp_dir().join(format!("fm84-go-to-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub/deeper")).unwrap();
std::fs::write(dir.join("a.txt"), "text").unwrap();
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("sub".as_ref()));
let alt = |app_state: &mut AppState, code: KeyCode| handle_key(app_state, KeyEvent::new(code, KeyModifiers::ALT));
let ctrl_l = |app_state: &mut AppState| handle_key(app_state, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
let cursor = |app_state: &AppState| {
let index = app_state.state_left.selected().unwrap();
app_state.children_left[index].name.clone()
};
let type_in = |app_state: &mut AppState, text: &str| {
let Some(Dialog::Prompt(PromptKind::GoTo, input)) = &mut app_state.dialog else { panic!("no prompt") };
input.set(text.to_string());
};
ctrl_l(&mut app_state);
let Some(Dialog::Prompt(PromptKind::GoTo, input)) = &app_state.dialog else { panic!("no prompt") };
assert_eq!(input.text, format!("{}{}", dir.display(), std::path::MAIN_SEPARATOR));
press(&mut app_state, KeyCode::Char('s'));
press(&mut app_state, KeyCode::Tab);
press(&mut app_state, KeyCode::Char('d'));
press(&mut app_state, KeyCode::Tab);
press(&mut app_state, KeyCode::Enter);
assert_eq!(app_state.dir_left, dir.join("sub/deeper"));
ctrl_l(&mut app_state);
type_in(&mut app_state, "../../a.txt");
press(&mut app_state, KeyCode::Enter);
assert_eq!((app_state.dir_left.clone(), cursor(&app_state)), (dir.clone(), "a".to_string()));
ctrl_l(&mut app_state);
type_in(&mut app_state, "nowhere");
press(&mut app_state, KeyCode::Enter);
assert!(app_state.error.take().unwrap().contains("nowhere"));
assert_eq!(app_state.dir_left, dir);
alt(&mut app_state, KeyCode::Left);
assert_eq!(app_state.dir_left, dir.join("sub/deeper"));
alt(&mut app_state, KeyCode::Left);
assert_eq!((app_state.dir_left.clone(), cursor(&app_state)), (dir.clone(), "sub".to_string()));
alt(&mut app_state, KeyCode::Right);
assert_eq!(app_state.dir_left, dir.join("sub/deeper"));
press(&mut app_state, KeyCode::Backspace);
assert_eq!(app_state.dir_left, dir.join("sub"));
alt(&mut app_state, KeyCode::Right);
assert_eq!(app_state.dir_left, dir.join("sub"));
alt(&mut app_state, KeyCode::Left);
assert_eq!(app_state.dir_left, dir.join("sub/deeper"));
std::fs::remove_dir(dir.join("sub/deeper")).unwrap();
alt(&mut app_state, KeyCode::Right);
assert_eq!(app_state.dir_left, dir.join("sub"));
alt(&mut app_state, KeyCode::Left);
assert_eq!(app_state.dir_left, dir, "the gone one was passed over");
alt(&mut app_state, KeyCode::Right);
assert_eq!(app_state.dir_left, dir.join("sub"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn alt_shift_enter_sizes_every_directory() {
let dir = std::env::temp_dir().join(format!("fm84-size-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let count = crate::constants::SIZE_WORKERS * 2 + 1;
for n in 0..count {
let sub = dir.join(format!("dir{n:02}"));
std::fs::create_dir_all(sub.join("inner")).unwrap();
std::fs::write(sub.join("a.bin"), vec![0u8; n]).unwrap();
std::fs::write(sub.join("inner").join("b.bin"), vec![0u8; 100]).unwrap();
}
std::fs::write(dir.join("file.txt"), "not a directory").unwrap();
std::os::unix::fs::symlink(&dir, dir.join("loop")).unwrap();
for modifiers in [KeyModifiers::ALT | KeyModifiers::SHIFT, KeyModifiers::ALT] {
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
handle_key(&mut app_state, KeyEvent::new(KeyCode::Enter, modifiers));
assert_eq!(app_state.dir_left, dir, "it sizes, and does not go in");
assert_eq!(app_state.dir_sizing.len(), crate::constants::SIZE_WORKERS);
assert_eq!(app_state.dir_size_queue.len(), count - crate::constants::SIZE_WORKERS);
assert_eq!(app_state.dir_size_so_far(app_state.dir_size_queue.back().unwrap()), Some(0));
let started = std::time::Instant::now();
while !app_state.dir_sizing.is_empty() || !app_state.dir_size_queue.is_empty() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "sizing never finished");
assert!(app_state.dir_sizing.len() <= crate::constants::SIZE_WORKERS);
std::thread::sleep(std::time::Duration::from_millis(2));
app_state.poll_dir_sizes();
}
for n in 0..count {
assert_eq!(app_state.dir_sizes.get(&dir.join(format!("dir{n:02}"))), Some(&(n as u64 + 100)), "dir{n:02}");
}
assert_eq!(app_state.dir_sizes.len(), count, "neither the link nor the file");
}
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
handle_key(&mut app_state, KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT | KeyModifiers::SHIFT));
press(&mut app_state, KeyCode::Esc);
assert!(app_state.dir_sizing.is_empty() && app_state.dir_size_queue.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_directory_sized_on_its_own_goes_ahead_of_those_waiting() {
let mut app_state = AppState::new();
for n in 0..crate::constants::SIZE_WORKERS {
app_state.start_dir_size(std::path::PathBuf::from(format!("/nonexistent/fm84/busy{n}")));
}
let waiting: Vec<std::path::PathBuf> = (0..3).map(|n| std::path::PathBuf::from(format!("/nonexistent/fm84/{n}"))).collect();
app_state.dir_size_queue.extend(waiting.iter().cloned());
app_state.start_dir_size(waiting[2].clone());
assert_eq!(app_state.dir_size_queue.front(), Some(&waiting[2]));
assert_eq!(app_state.dir_size_queue.len(), 3, "moved, not added again");
app_state.cancel_dir_sizes();
}
#[test]
fn leaving_a_directory_stops_sizing_what_is_in_it() {
let dir = std::env::temp_dir().join(format!("fm84-size-leave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for n in 0..crate::constants::SIZE_WORKERS * 2 {
std::fs::create_dir_all(dir.join(format!("d{n}"))).unwrap();
}
let elsewhere = std::env::temp_dir();
let size_all = |app_state: &mut AppState| handle_key(app_state, KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT | KeyModifiers::SHIFT));
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
app_state.open_dir(false, elsewhere.clone(), None);
app_state.dir_sizes.insert(dir.join("d0"), 42);
size_all(&mut app_state);
assert!(!app_state.dir_sizing.is_empty() && !app_state.dir_size_queue.is_empty());
app_state.open_dir(true, elsewhere.clone(), None);
assert!(app_state.dir_sizing.is_empty(), "running ones stopped");
assert!(app_state.dir_size_queue.is_empty(), "waiting ones dropped");
assert_eq!(app_state.dir_sizes.get(&dir.join("d0")), Some(&42), "a size counted before is kept");
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
app_state.open_dir(false, dir.clone(), None);
size_all(&mut app_state);
let asked = app_state.dir_sizing.len() + app_state.dir_size_queue.len();
app_state.open_dir(true, elsewhere.clone(), None);
assert_eq!(app_state.dir_sizing.len() + app_state.dir_size_queue.len(), asked);
app_state.cancel_dir_sizes();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ctrl_s_filters_the_panel_as_it_is_typed() {
let dir = std::env::temp_dir().join(format!("fm84-filter-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("reports")).unwrap();
for name in ["report.txt", "notes.md", "main.rs"] {
std::fs::write(dir.join(name), "").unwrap();
}
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("notes.md".as_ref()));
app_state.selected_left.insert("main.rs".into());
app_state.selected_left.insert("report.txt".into());
let names = |app_state: &AppState| {
let mut names: Vec<String> = app_state.children_left.iter().map(|item| item.name_full.clone()).collect();
names.sort();
names
};
let shown = names(&app_state);
let ctrl_s = |app_state: &mut AppState| handle_key(app_state, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
ctrl_s(&mut app_state);
assert!(matches!(app_state.dialog, Some(Dialog::Prompt(PromptKind::Filter, _))));
for c in "rep".chars() {
press(&mut app_state, KeyCode::Char(c));
}
assert_eq!(names(&app_state), ["..", "report.txt", "reports"]);
assert_eq!(app_state.state_left.selected(), Some(1));
assert!(!app_state.children_left[1].is_parent());
assert_eq!(app_state.selected_left, std::collections::HashSet::from(["report.txt".into()]));
press(&mut app_state, KeyCode::Esc);
assert!(app_state.dialog.is_none() && app_state.filter_left.is_none());
assert_eq!(names(&app_state), shown);
ctrl_s(&mut app_state);
for c in "*.md;*.rs".chars() {
press(&mut app_state, KeyCode::Char(c));
}
press(&mut app_state, KeyCode::Enter);
assert_eq!(names(&app_state), ["..", "main.rs", "notes.md"]);
assert_eq!(app_state.filter_left.as_ref().map(|filter| filter.text.as_str()), Some("*.md;*.rs"));
ctrl_s(&mut app_state);
press(&mut app_state, KeyCode::Backspace);
assert_eq!(names(&app_state), ["..", "notes.md"]);
press(&mut app_state, KeyCode::Esc);
assert_eq!(names(&app_state), ["..", "main.rs", "notes.md"]);
std::fs::write(dir.join("lib.rs"), "").unwrap();
std::fs::write(dir.join("todo.txt"), "").unwrap();
app_state.reload_panel(true, None);
assert_eq!(names(&app_state), ["..", "lib.rs", "main.rs", "notes.md"]);
app_state.open_dir(true, dir.join("reports"), None);
assert!(app_state.filter_left.is_none());
app_state.open_dir(true, dir.clone(), None);
assert_eq!(app_state.children_left.len(), shown.len() + 2);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn alt_down_goes_back_anywhere_that_has_been() {
let dir = std::env::temp_dir().join(format!("fm84-history-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for name in ["music/jazz", "music/rock", "photos", "gone"] {
std::fs::create_dir_all(dir.join(name)).unwrap();
}
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
app_state.open_dir(true, dir.join("music/jazz"), None);
app_state.new_tab();
app_state.open_dir(true, dir.join("photos"), None);
app_state.open_dir(false, dir.join("music/rock"), None);
app_state.open_dir(true, dir.join("gone"), None);
app_state.open_dir(true, dir.join("music/jazz"), None);
let shown = |app_state: &AppState| match &app_state.dialog {
Some(Dialog::History(list)) => (list.shown.iter().map(|path| path.strip_prefix(&dir).unwrap_or(path).to_path_buf()).collect::<Vec<_>>(), list.index),
_ => panic!("not open"),
};
let alt_down = |app_state: &mut AppState| handle_key(app_state, KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
alt_down(&mut app_state);
let (list, index) = shown(&app_state);
let names: Vec<&str> = list.iter().map(|path| path.to_str().unwrap()).collect();
assert_eq!(&names[..5], ["music/jazz", "gone", "music/rock", "photos", ""]);
assert_eq!(index, 1, "on the newest that is not where the panel is");
for c in "mus k".chars() {
press(&mut app_state, KeyCode::Char(c));
}
assert_eq!(shown(&app_state).0, [std::path::PathBuf::from("music/rock")]);
press(&mut app_state, KeyCode::Backspace);
press(&mut app_state, KeyCode::Backspace);
assert_eq!(shown(&app_state).0, [std::path::PathBuf::from("music/jazz"), std::path::PathBuf::from("music/rock")]);
press(&mut app_state, KeyCode::Down);
press(&mut app_state, KeyCode::Enter);
assert!(app_state.dialog.is_none());
assert_eq!(app_state.dir_left, dir.join("music/rock"));
std::fs::remove_dir(dir.join("gone")).unwrap();
alt_down(&mut app_state);
for c in "gone".chars() {
press(&mut app_state, KeyCode::Char(c));
}
press(&mut app_state, KeyCode::Enter);
assert!(app_state.error.is_some());
assert_eq!(app_state.dir_left, dir.join("music/rock"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ctrl_b_lists_every_file_below() {
let dir = std::env::temp_dir().join(format!("fm84-flat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub/deep")).unwrap();
std::fs::create_dir_all(dir.join(".secret")).unwrap();
std::fs::write(dir.join("a.txt"), "a").unwrap();
std::fs::write(dir.join("sub/b.bin"), "b".repeat(100)).unwrap();
std::fs::write(dir.join("sub/deep/c.txt"), "c".repeat(10)).unwrap();
std::fs::write(dir.join(".secret/key"), "").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(dir.join("sub"), dir.join("again")).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.options.show_hidden = false;
app_state.options.sort_key = crate::options::SortKey::Name;
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("sub".as_ref()));
let names = |app_state: &AppState| app_state.children_left.iter().map(|item| item.name_full.clone()).collect::<Vec<_>>();
let cursor = |app_state: &AppState| app_state.children_left[app_state.state_left.selected().unwrap()].name_full.clone();
let ctrl_b = |app_state: &mut AppState| handle_key(app_state, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL));
let sep = std::path::MAIN_SEPARATOR;
ctrl_b(&mut app_state);
assert_eq!(app_state.flat_left.as_ref(), Some(&dir));
assert_eq!(names(&app_state), ["..".to_string(), "a.txt".to_string(), format!("sub{sep}b.bin"), format!("sub{sep}deep{sep}c.txt")]);
let deep = app_state.children_left.iter().find(|item| item.name_full.ends_with("c.txt")).unwrap();
assert_eq!((deep.name.as_str(), deep.extension.as_str()), (format!("sub{sep}deep{sep}c").as_str(), "txt"));
handle_key(&mut app_state, KeyEvent::new(KeyCode::F(6), KeyModifiers::CONTROL));
assert_eq!(names(&app_state)[1..], ["a.txt".to_string(), format!("sub{sep}deep{sep}c.txt"), format!("sub{sep}b.bin")]);
assert!(app_state.flat_left.is_some(), "a reread keeps it");
let at = app_state.children_left.iter().position(|item| item.name_full.ends_with("c.txt")).unwrap();
app_state.state_left.select(Some(at));
press(&mut app_state, KeyCode::F(2));
assert_eq!(app_state.dialog_input().unwrap().text, "c.txt");
app_state.dialog_input().unwrap().set("d.txt".to_string());
press(&mut app_state, KeyCode::Enter);
assert!(dir.join("sub/deep/d.txt").exists() && app_state.error.is_none());
assert_eq!(cursor(&app_state), format!("sub{sep}deep{sep}d.txt"));
ctrl_b(&mut app_state);
assert!(app_state.flat_left.is_none());
assert_eq!(cursor(&app_state), "sub");
ctrl_b(&mut app_state);
press(&mut app_state, KeyCode::Backspace);
assert!(app_state.flat_left.is_none());
app_state.open_dir(true, dir.clone(), None);
assert!(names(&app_state).contains(&"sub".to_string()));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_flat_walk_stops_when_told() {
let stop = std::sync::atomic::AtomicBool::new(true);
let options = crate::options::Options::default();
let walked = crate::fs_ops::load_flat_rows(&std::env::temp_dir(), &options, &stop);
assert!(walked.is_err_and(|e| e.kind() == std::io::ErrorKind::Interrupted) || std::fs::read_dir(std::env::temp_dir()).unwrap().next().is_none());
}