use super::super::{App, state::DirectoryLoadCompletion};
use super::rename;
use std::{
fs,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
fn wait_for_trash_and_reload(app: &mut App) {
for _ in 0..500 {
let _ = app.process_background_jobs();
if app.trash_progress().is_none() && app.navigation.directory_runtime.pending_load.is_none()
{
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("timed out waiting for trash and directory reload to complete");
}
fn wait_for_restore_and_reload(app: &mut App) {
for _ in 0..500 {
let _ = app.process_background_jobs();
if app.restore_progress().is_none()
&& app.navigation.directory_runtime.pending_load.is_none()
{
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("timed out waiting for restore and directory reload to complete");
}
fn temp_path(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("elio-create-{label}-{unique}"))
}
fn take_pending_status(app: &mut App) -> (String, Option<PathBuf>) {
let load = app
.navigation
.directory_runtime
.pending_load
.take()
.expect("expected queued directory load");
let status = match load.completion {
DirectoryLoadCompletion::Status(status) => status,
DirectoryLoadCompletion::Keep => {
panic!("expected status completion, got keep")
}
DirectoryLoadCompletion::Clear => {
panic!("expected status completion, got clear")
}
};
(status, load.reselect_path)
}
fn encode_trashinfo_path(path: &Path) -> String {
path.to_string_lossy()
.replace('%', "%25")
.replace(' ', "%20")
}
fn create_fake_trash_file(label: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
let root = temp_path(label);
let originals_dir = root.join("originals");
let trash_files = root.join("Trash/files");
let trash_info = root.join("Trash/info");
fs::create_dir_all(&originals_dir).expect("failed to create originals dir");
fs::create_dir_all(&trash_files).expect("failed to create trash files dir");
fs::create_dir_all(&trash_info).expect("failed to create trash info dir");
let original_path = originals_dir.join("restore-target.txt");
fs::write(&original_path, "restore me").expect("failed to write original file");
let trashed_path = trash_files.join("restore-target.txt");
fs::rename(&original_path, &trashed_path).expect("failed to move file into fake trash");
fs::write(
trash_info.join("restore-target.txt.trashinfo"),
format!(
"[Trash Info]\nPath={}\nDeletionDate=2026-03-21T00:00:00\n",
encode_trashinfo_path(&original_path)
),
)
.expect("failed to write trashinfo");
(root, trash_files, original_path, trashed_path)
}
#[test]
fn confirm_create_creates_files_and_folders_and_reselects_last_created_path() {
let root = temp_path("create-success");
fs::create_dir_all(&root).expect("failed to create temp root");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.open_create_prompt();
let overlay = app
.overlays
.create
.as_mut()
.expect("create overlay should be open");
overlay.lines = vec!["notes.txt".to_string(), "/docs/".to_string()];
overlay.line_errors = vec![None; overlay.lines.len()];
app.confirm_create().expect("create should succeed");
assert!(app.overlays.create.is_none());
assert!(root.join("notes.txt").is_file());
assert!(root.join("docs").is_dir());
let (status, reselect_path) = take_pending_status(&mut app);
assert_eq!(status, "Created 1 file and 1 folder");
assert_eq!(reselect_path, Some(root.join("docs")));
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_create_reports_duplicate_names_after_dir_marker_normalization() {
let root = temp_path("create-duplicates");
fs::create_dir_all(&root).expect("failed to create temp root");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.open_create_prompt();
let overlay = app
.overlays
.create
.as_mut()
.expect("create overlay should be open");
overlay.lines = vec!["logs/".to_string(), "/logs".to_string()];
overlay.line_errors = vec![None; overlay.lines.len()];
app.confirm_create()
.expect("create validation should succeed");
let overlay = app
.overlays
.create
.as_ref()
.expect("create overlay should stay open");
assert_eq!(overlay.cursor_line, 1);
assert_eq!(
overlay.line_errors[1].as_deref(),
Some("\"logs\" appears more than once")
);
assert!(!root.join("logs").exists());
assert!(app.navigation.directory_runtime.pending_load.is_none());
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_rename_renames_selected_entry_and_queues_reselect() {
let root = temp_path("rename-success");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("report.txt"), "draft").expect("failed to write source file");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.open_rename_prompt();
let overlay = app
.overlays
.rename
.as_mut()
.expect("rename overlay should be open");
assert_eq!(overlay.original_name, "report.txt");
assert_eq!(overlay.cursor_col, 6);
overlay.input = "summary.txt".to_string();
app.confirm_rename().expect("rename should succeed");
assert!(app.overlays.rename.is_none());
assert!(!root.join("report.txt").exists());
assert!(root.join("summary.txt").is_file());
let (status, reselect_path) = take_pending_status(&mut app);
assert_eq!(status, "Renamed \"report.txt\" → \"summary.txt\"");
assert_eq!(reselect_path, Some(root.join("summary.txt")));
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn cursor_before_extension_skips_hidden_file_prefix_dot() {
assert_eq!(rename::cursor_before_extension(".env"), 4);
assert_eq!(rename::cursor_before_extension("report.txt"), 6);
assert_eq!(rename::cursor_before_extension("archive.tar.gz"), 11);
}
#[test]
fn confirm_bulk_rename_renames_changed_entries_and_skips_unchanged_rows() {
let root = temp_path("bulk-rename-success");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "alpha").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "beta").expect("failed to write beta");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.selected_paths.insert(root.join("alpha.txt"));
app.navigation.selected_paths.insert(root.join("beta.txt"));
app.open_bulk_rename_prompt();
let overlay = app
.overlays
.bulk_rename
.as_mut()
.expect("bulk rename overlay should be open");
assert_eq!(overlay.new_names, vec!["alpha.txt", "beta.txt"]);
overlay.new_names[0] = "gamma.txt".to_string();
app.confirm_bulk_rename()
.expect("bulk rename should succeed");
assert!(app.overlays.bulk_rename.is_none());
assert!(root.join("gamma.txt").is_file());
assert!(root.join("beta.txt").is_file());
assert!(!root.join("alpha.txt").exists());
assert!(app.navigation.selected_paths.is_empty());
let (status, reselect_path) = take_pending_status(&mut app);
assert_eq!(status, "Renamed \"alpha.txt\" → \"gamma.txt\"");
assert_eq!(reselect_path, Some(root.join("gamma.txt")));
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_bulk_rename_reports_duplicate_destination_names() {
let root = temp_path("bulk-rename-duplicates");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "alpha").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "beta").expect("failed to write beta");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.selected_paths.insert(root.join("alpha.txt"));
app.navigation.selected_paths.insert(root.join("beta.txt"));
app.open_bulk_rename_prompt();
let overlay = app
.overlays
.bulk_rename
.as_mut()
.expect("bulk rename overlay should be open");
overlay.new_names = vec!["shared.txt".to_string(), "shared.txt".to_string()];
app.confirm_bulk_rename()
.expect("bulk rename validation should succeed");
let overlay = app
.overlays
.bulk_rename
.as_ref()
.expect("bulk rename overlay should stay open");
assert_eq!(overlay.cursor_line, 1);
assert_eq!(
overlay.line_errors[1].as_deref(),
Some("\"shared.txt\" appears more than once")
);
assert!(root.join("alpha.txt").is_file());
assert!(root.join("beta.txt").is_file());
assert!(app.navigation.directory_runtime.pending_load.is_none());
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_trash_permanently_deletes_selected_items_inside_trash() {
let root = temp_path("trash-permanent");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("gone.txt"), "bye").expect("failed to write file");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation.selected_paths.insert(root.join("gone.txt"));
app.open_trash_prompt();
assert_eq!(app.trash_title(), "Delete permanently 1 selected file?");
app.confirm_trash().expect("trash should succeed");
assert!(app.overlays.trash.is_none());
assert!(app.navigation.selected_paths.is_empty());
wait_for_trash_and_reload(&mut app);
assert!(!root.join("gone.txt").exists());
assert_eq!(app.status_message(), "Permanently deleted \"gone.txt\"");
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn after_delete_cursor_moves_to_next_surviving_entry() {
let root = temp_path("cursor-next-after-delete");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "a").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "b").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "c").expect("failed to write gamma");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation.selected = 1; app.remember_current_directory_view(); app.open_trash_prompt();
app.confirm_trash().expect("trash should succeed");
wait_for_trash_and_reload(&mut app);
assert!(!root.join("beta.txt").exists());
assert_eq!(
app.selected_entry().map(|e| e.name.as_str()),
Some("gamma.txt"),
"cursor should land on gamma.txt (next surviving entry)"
);
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn after_delete_cursor_falls_back_to_previous_entry_when_last_is_deleted() {
let root = temp_path("cursor-prev-after-delete");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "a").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "b").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "c").expect("failed to write gamma");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation.selected = 2; app.remember_current_directory_view(); app.open_trash_prompt();
app.confirm_trash().expect("trash should succeed");
wait_for_trash_and_reload(&mut app);
assert!(!root.join("gamma.txt").exists());
assert_eq!(
app.selected_entry().map(|e| e.name.as_str()),
Some("beta.txt"),
"cursor should fall back to beta.txt (last surviving entry before cursor)"
);
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn cancelled_delete_does_not_move_cursor_away_from_surviving_entry() {
let root = temp_path("cursor-cancel-delete");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "a").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "b").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "c").expect("failed to write gamma");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation.selected = 1; app.remember_current_directory_view(); app.open_trash_prompt();
app.confirm_trash().expect("trash should succeed");
app.jobs.scheduler.cancel_trash(app.jobs.trash_token);
wait_for_trash_and_reload(&mut app);
if root.join("beta.txt").exists() {
assert_eq!(
app.selected_entry().map(|e| e.name.as_str()),
Some("beta.txt"),
"cursor must stay on beta.txt when cancel won the race"
);
}
assert!(
app.selected_entry().is_none()
|| root
.join(app.selected_entry().unwrap().name.as_str())
.exists(),
"cursor must point to a surviving entry"
);
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_trash_batch_trashes_multiple_files_and_reports_count() {
let root = temp_path("trash-batch-multi");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("alpha.txt"), "a").expect("failed to write alpha");
fs::write(root.join("beta.txt"), "b").expect("failed to write beta");
fs::write(root.join("gamma.txt"), "c").expect("failed to write gamma");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.selected_paths.insert(root.join("alpha.txt"));
app.navigation.selected_paths.insert(root.join("beta.txt"));
app.navigation.selected_paths.insert(root.join("gamma.txt"));
app.open_trash_prompt();
assert_eq!(app.trash_title(), "Trash 3 files?");
app.confirm_trash().expect("trash should succeed");
assert!(app.overlays.trash.is_none());
assert!(app.navigation.selected_paths.is_empty());
wait_for_trash_and_reload(&mut app);
assert!(!root.join("alpha.txt").exists());
assert!(!root.join("beta.txt").exists());
assert!(!root.join("gamma.txt").exists());
assert_eq!(app.status_message(), "Trashed 3 items");
#[cfg(all(unix, not(target_os = "macos")))]
{
use trash::os_limited::{list, purge_all};
if let Ok(items) = list() {
let ours: Vec<_> = items
.into_iter()
.filter(|item| item.original_parent == root)
.collect();
let _ = purge_all(ours);
}
}
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_trash_batch_single_file_shows_quoted_name() {
let root = temp_path("trash-batch-single");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("notes.txt"), "hello").expect("failed to write file");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.selected_paths.insert(root.join("notes.txt"));
app.open_trash_prompt();
assert_eq!(app.trash_title(), "Trash 1 selected file?");
app.confirm_trash().expect("trash should succeed");
assert!(app.overlays.trash.is_none());
assert!(app.navigation.selected_paths.is_empty());
wait_for_trash_and_reload(&mut app);
assert!(!root.join("notes.txt").exists());
assert_eq!(app.status_message(), "Trashed \"notes.txt\"");
#[cfg(all(unix, not(target_os = "macos")))]
{
use trash::os_limited::{list, purge_all};
if let Ok(items) = list() {
let ours: Vec<_> = items
.into_iter()
.filter(|item| item.original_parent == root)
.collect();
let _ = purge_all(ours);
}
}
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn esc_during_batched_trash_keeps_chip_visible_until_done() {
let root = temp_path("trash-cancel-batched");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("canary.txt"), "x").expect("failed to write file");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation
.selected_paths
.insert(root.join("canary.txt"));
app.open_trash_prompt();
app.confirm_trash().expect("trash should succeed");
assert!(
app.trash_progress().is_some(),
"chip should be visible after submit"
);
app.jobs.scheduler.cancel_trash(app.jobs.trash_token);
assert!(
app.trash_progress().is_some(),
"chip must remain visible after Esc for batched trash"
);
wait_for_trash_and_reload(&mut app);
assert!(
app.trash_progress().is_none(),
"chip should be gone after completion"
);
let status = app.status_message();
let valid = status.starts_with("Trash cancelled")
|| status.starts_with("Trashed")
|| status.starts_with("Nothing was deleted");
assert!(valid, "unexpected status: {status:?}");
#[cfg(all(unix, not(target_os = "macos")))]
if !root.join("canary.txt").exists() {
use trash::os_limited::{list, purge_all};
if let Ok(items) = list() {
let ours: Vec<_> = items
.into_iter()
.filter(|item| item.original_parent == root)
.collect();
let _ = purge_all(ours);
}
}
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn esc_during_permanent_delete_clears_chip_immediately() {
let root = temp_path("trash-cancel-permanent");
fs::create_dir_all(&root).expect("failed to create temp root");
fs::write(root.join("gone.txt"), "x").expect("failed to write file");
let mut app = App::new_at(root.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation.selected_paths.insert(root.join("gone.txt"));
app.open_trash_prompt();
app.confirm_trash().expect("trash should succeed");
assert!(
app.trash_progress().is_some(),
"chip should be visible after submit"
);
let token = app.jobs.trash_token;
app.jobs.scheduler.cancel_trash(token);
app.jobs.trash_progress = None;
assert!(
app.trash_progress().is_none(),
"chip should clear immediately for permanent delete"
);
for _ in 0..200 {
let _ = app.process_background_jobs();
std::thread::sleep(Duration::from_millis(10));
}
app.navigation.directory_runtime.watch = None;
drop(app);
let _ = fs::remove_dir_all(root);
}
#[test]
fn confirm_restore_restores_file_from_trashinfo_and_queues_reload() {
let (root, trash_files, original_path, trashed_path) = create_fake_trash_file("restore");
let mut app = App::new_at(trash_files.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.open_restore_prompt();
assert_eq!(app.restore_title(), "Restore 1 selected file?");
app.confirm_restore().expect("restore should succeed");
assert!(app.overlays.restore.is_none());
assert!(app.navigation.selected_paths.is_empty());
wait_for_restore_and_reload(&mut app);
assert!(original_path.is_file());
assert!(!trashed_path.exists());
assert_eq!(app.status_message(), "Restored \"restore-target.txt\"");
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn confirm_restore_bulk_restores_multiple_files_and_reports_count() {
let root = temp_path("restore-bulk");
let originals_dir = root.join("originals");
let trash_files = root.join("Trash/files");
let trash_info = root.join("Trash/info");
fs::create_dir_all(&originals_dir).expect("failed to create originals dir");
fs::create_dir_all(&trash_files).expect("failed to create trash files dir");
fs::create_dir_all(&trash_info).expect("failed to create trash info dir");
for name in ["alpha.txt", "beta.txt"] {
let original = originals_dir.join(name);
let trashed = trash_files.join(name);
fs::write(&original, name).expect("failed to write original");
fs::rename(&original, &trashed).expect("failed to move to fake trash");
fs::write(
trash_info.join(format!("{name}.trashinfo")),
format!(
"[Trash Info]\nPath={}\nDeletionDate=2026-03-23T00:00:00\n",
encode_trashinfo_path(&original)
),
)
.expect("failed to write trashinfo");
}
let mut app = App::new_at(trash_files.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.navigation
.selected_paths
.insert(trash_files.join("alpha.txt"));
app.navigation
.selected_paths
.insert(trash_files.join("beta.txt"));
app.open_restore_prompt();
assert_eq!(app.restore_title(), "Restore 2 files?");
app.confirm_restore().expect("restore should succeed");
assert!(app.overlays.restore.is_none());
assert!(app.navigation.selected_paths.is_empty());
wait_for_restore_and_reload(&mut app);
assert!(originals_dir.join("alpha.txt").is_file());
assert!(originals_dir.join("beta.txt").is_file());
assert!(!trash_files.join("alpha.txt").exists());
assert!(!trash_files.join("beta.txt").exists());
assert_eq!(app.status_message(), "Restored 2 items");
app.navigation.directory_runtime.watch = None;
drop(app);
fs::remove_dir_all(root).expect("failed to remove temp root");
}
#[test]
fn esc_during_restore_clears_chip_immediately() {
let (root, trash_files, _original_path, _trashed_path) =
create_fake_trash_file("restore-cancel");
let mut app = App::new_at(trash_files.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.open_restore_prompt();
app.confirm_restore().expect("restore should succeed");
assert!(
app.restore_progress().is_some(),
"chip should be visible after submit"
);
let token = app.jobs.restore_token;
app.jobs.scheduler.cancel_restore(token);
app.jobs.restore_progress = None;
assert!(
app.restore_progress().is_none(),
"chip should clear immediately after Esc for restore"
);
for _ in 0..200 {
let _ = app.process_background_jobs();
if app.jobs.restore_source_cwd.is_none()
&& app.navigation.directory_runtime.pending_load.is_none()
{
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let status = app.status_message();
let valid = status.starts_with("Restore cancelled")
|| status.starts_with("Restored")
|| status.starts_with("Nothing was restored");
assert!(valid, "unexpected status: {status:?}");
app.navigation.directory_runtime.watch = None;
drop(app);
let _ = fs::remove_dir_all(root);
}
#[test]
fn confirm_restore_while_in_progress_shows_status_and_dismisses_overlay() {
let (root, trash_files, _original_path, _trashed_path) =
create_fake_trash_file("restore-in-progress");
let mut app = App::new_at(trash_files.clone()).expect("failed to create app");
app.navigation.in_trash = true;
app.open_restore_prompt();
app.confirm_restore().expect("first restore should succeed");
app.open_restore_prompt();
assert!(app.overlays.restore.is_some(), "overlay should open");
app.confirm_restore()
.expect("second confirm should not error");
assert!(
app.overlays.restore.is_none(),
"overlay should be dismissed by the in-progress guard"
);
assert_eq!(
app.status, "Restore in progress — press Esc to cancel",
"in-progress message should be shown"
);
for _ in 0..200 {
let _ = app.process_background_jobs();
if app.restore_progress().is_none()
&& app.navigation.directory_runtime.pending_load.is_none()
{
break;
}
std::thread::sleep(Duration::from_millis(10));
}
app.navigation.directory_runtime.watch = None;
drop(app);
let _ = fs::remove_dir_all(root);
}