use std::{
fs,
io::{self},
path::{Path, PathBuf},
};
#[derive(Debug, Clone)]
pub struct AppOptions {
pub left_dir: PathBuf,
pub right_dir: PathBuf,
pub extensions: Vec<String>,
pub show_hidden: bool,
pub theme_idx: usize,
pub show_theme_panel: bool,
pub single_pane: bool,
pub sort_mode: SortMode,
}
impl Default for AppOptions {
fn default() -> Self {
Self {
left_dir: PathBuf::from("."),
right_dir: PathBuf::from("."),
extensions: vec![],
show_hidden: false,
theme_idx: 0,
show_theme_panel: false,
single_pane: false,
sort_mode: SortMode::default(),
}
}
}
use crate::fs::copy_dir_all;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use tui_file_explorer::{ExplorerOutcome, FileExplorer, SortMode, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Left,
Right,
}
impl Pane {
pub fn other(self) -> Self {
match self {
Pane::Left => Pane::Right,
Pane::Right => Pane::Left,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipOp {
Copy,
Cut,
}
#[derive(Debug, Clone)]
pub struct ClipboardItem {
pub path: PathBuf,
pub op: ClipOp,
}
impl ClipboardItem {
pub fn icon(&self) -> &'static str {
match self.op {
ClipOp::Copy => "\u{1F4CB}", ClipOp::Cut => "\u{2702} ", }
}
pub fn label(&self) -> &'static str {
match self.op {
ClipOp::Copy => "Copy",
ClipOp::Cut => "Cut ",
}
}
}
#[derive(Debug)]
pub enum Modal {
Delete {
path: PathBuf,
},
MultiDelete {
paths: Vec<PathBuf>,
},
Overwrite {
src: PathBuf,
dst: PathBuf,
is_cut: bool,
},
}
pub struct App {
pub left: FileExplorer,
pub right: FileExplorer,
pub active: Pane,
pub clipboard: Option<ClipboardItem>,
pub themes: Vec<(&'static str, &'static str, Theme)>,
pub theme_idx: usize,
pub show_theme_panel: bool,
pub single_pane: bool,
pub modal: Option<Modal>,
pub selected: Option<PathBuf>,
pub status_msg: String,
}
impl App {
pub fn new(opts: AppOptions) -> Self {
let left = FileExplorer::builder(opts.left_dir)
.extension_filter(opts.extensions.clone())
.show_hidden(opts.show_hidden)
.sort_mode(opts.sort_mode)
.build();
let right = FileExplorer::builder(opts.right_dir)
.extension_filter(opts.extensions)
.show_hidden(opts.show_hidden)
.sort_mode(opts.sort_mode)
.build();
Self {
left,
right,
active: Pane::Left,
clipboard: None,
themes: Theme::all_presets(),
theme_idx: opts.theme_idx,
show_theme_panel: opts.show_theme_panel,
single_pane: opts.single_pane,
modal: None,
selected: None,
status_msg: String::new(),
}
}
pub fn active_pane(&self) -> &FileExplorer {
match self.active {
Pane::Left => &self.left,
Pane::Right => &self.right,
}
}
pub fn active_pane_mut(&mut self) -> &mut FileExplorer {
match self.active {
Pane::Left => &mut self.left,
Pane::Right => &mut self.right,
}
}
pub fn theme(&self) -> &Theme {
&self.themes[self.theme_idx].2
}
pub fn theme_name(&self) -> &str {
self.themes[self.theme_idx].0
}
pub fn theme_desc(&self) -> &str {
self.themes[self.theme_idx].1
}
pub fn next_theme(&mut self) {
self.theme_idx = (self.theme_idx + 1) % self.themes.len();
}
pub fn prev_theme(&mut self) {
self.theme_idx = self
.theme_idx
.checked_sub(1)
.unwrap_or(self.themes.len() - 1);
}
pub fn yank(&mut self, op: ClipOp) {
if let Some(entry) = self.active_pane().current_entry() {
let label = entry.name.clone();
self.clipboard = Some(ClipboardItem {
path: entry.path.clone(),
op,
});
let (verb, hint) = if op == ClipOp::Copy {
("Copied", "paste a copy")
} else {
("Cut", "move it")
};
self.status_msg = format!("{verb} '{label}' — press p in other pane to {hint}");
}
}
pub fn paste(&mut self) {
let Some(clip) = self.clipboard.clone() else {
self.status_msg = "Nothing in clipboard.".into();
return;
};
let dst_dir = self.active_pane().current_dir.clone();
let file_name = match clip.path.file_name() {
Some(n) => n.to_owned(),
None => {
self.status_msg = "Cannot paste: clipboard path has no filename.".into();
return;
}
};
let dst = dst_dir.join(&file_name);
if clip.op == ClipOp::Cut && clip.path.parent() == Some(&dst_dir) {
self.status_msg = "Source and destination are the same — skipped.".into();
return;
}
if dst.exists() {
self.modal = Some(Modal::Overwrite {
src: clip.path,
dst,
is_cut: clip.op == ClipOp::Cut,
});
} else {
self.do_paste(&clip.path, &dst, clip.op == ClipOp::Cut);
}
}
pub fn do_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
let result = if src.is_dir() {
copy_dir_all(src, dst)
} else {
fs::copy(src, dst).map(|_| ())
};
match result {
Ok(()) => {
if is_cut {
let _ = if src.is_dir() {
fs::remove_dir_all(src)
} else {
fs::remove_file(src)
};
self.clipboard = None;
}
self.left.reload();
self.right.reload();
self.status_msg = format!(
"{} '{}'",
if is_cut { "Moved" } else { "Pasted" },
dst.file_name().unwrap_or_default().to_string_lossy()
);
}
Err(e) => {
self.status_msg = format!("Error: {e}");
}
}
}
pub fn prompt_delete(&mut self) {
let marked: Vec<PathBuf> = self.active_pane().marked.iter().cloned().collect();
if !marked.is_empty() {
let mut sorted = marked;
sorted.sort();
self.modal = Some(Modal::MultiDelete { paths: sorted });
} else if let Some(entry) = self.active_pane().current_entry() {
self.modal = Some(Modal::Delete {
path: entry.path.clone(),
});
}
}
pub fn confirm_delete_many(&mut self, paths: &[PathBuf]) {
let mut errors: Vec<String> = Vec::new();
let mut deleted: usize = 0;
for path in paths {
let result = if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
match result {
Ok(()) => deleted += 1,
Err(e) => errors.push(format!(
"'{}': {e}",
path.file_name().unwrap_or_default().to_string_lossy()
)),
}
}
self.left.clear_marks();
self.right.clear_marks();
self.left.reload();
self.right.reload();
if errors.is_empty() {
self.status_msg = format!("Deleted {deleted} item(s).");
} else {
self.status_msg = format!(
"Deleted {deleted}, {} error(s): {}",
errors.len(),
errors.join("; ")
);
}
}
pub fn confirm_delete(&mut self, path: &Path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let result = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
match result {
Ok(()) => {
self.left.reload();
self.right.reload();
self.status_msg = format!("Deleted '{name}'");
}
Err(e) => {
self.status_msg = format!("Delete failed: {e}");
}
}
}
pub fn handle_event(&mut self) -> io::Result<bool> {
let Event::Key(key) = event::read()? else {
return Ok(false);
};
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(true);
}
if let Some(modal) = self.modal.take() {
match &modal {
Modal::Delete { path } => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let p = path.clone();
self.confirm_delete(&p);
}
_ => self.status_msg = "Delete cancelled.".into(),
},
Modal::MultiDelete { paths } => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let ps = paths.clone();
self.confirm_delete_many(&ps);
}
_ => self.status_msg = "Multi-delete cancelled.".into(),
},
Modal::Overwrite { src, dst, is_cut } => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let (s, d, cut) = (src.clone(), dst.clone(), *is_cut);
self.do_paste(&s, &d, cut);
}
_ => self.status_msg = "Paste cancelled.".into(),
},
}
return Ok(false);
}
match key.code {
KeyCode::Char('t') if key.modifiers.is_empty() => {
self.next_theme();
return Ok(false);
}
KeyCode::Char('[') => {
self.prev_theme();
return Ok(false);
}
KeyCode::Char('T') => {
self.show_theme_panel = !self.show_theme_panel;
return Ok(false);
}
KeyCode::Tab => {
self.active = self.active.other();
return Ok(false);
}
KeyCode::Char('w') if key.modifiers.is_empty() => {
self.single_pane = !self.single_pane;
return Ok(false);
}
KeyCode::Char('y') if key.modifiers.is_empty() => {
self.yank(ClipOp::Copy);
return Ok(false);
}
KeyCode::Char('x') if key.modifiers.is_empty() => {
self.yank(ClipOp::Cut);
return Ok(false);
}
KeyCode::Char('p') if key.modifiers.is_empty() => {
self.paste();
return Ok(false);
}
KeyCode::Char('d') if key.modifiers.is_empty() => {
self.prompt_delete();
return Ok(false);
}
_ => {}
}
let outcome = self.active_pane_mut().handle_key(key);
match outcome {
ExplorerOutcome::Selected(path) => {
self.selected = Some(path);
return Ok(true);
}
ExplorerOutcome::Dismissed => return Ok(true),
ExplorerOutcome::Pending => {
if self.status_msg.starts_with("Error") || self.status_msg.starts_with("Delete") {
} else {
self.status_msg.clear();
}
}
ExplorerOutcome::Unhandled => {}
}
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn make_app(dir: PathBuf) -> App {
App::new(AppOptions {
left_dir: dir.clone(),
right_dir: dir,
..AppOptions::default()
})
}
#[test]
fn pane_other_left_returns_right() {
assert_eq!(Pane::Left.other(), Pane::Right);
}
#[test]
fn pane_other_right_returns_left() {
assert_eq!(Pane::Right.other(), Pane::Left);
}
#[test]
fn clipboard_item_copy_icon_and_label() {
let item = ClipboardItem {
path: PathBuf::from("/tmp/foo"),
op: ClipOp::Copy,
};
assert_eq!(item.icon(), "\u{1F4CB}");
assert_eq!(item.label(), "Copy");
}
#[test]
fn clipboard_item_cut_icon_and_label() {
let item = ClipboardItem {
path: PathBuf::from("/tmp/foo"),
op: ClipOp::Cut,
};
assert_eq!(item.icon(), "\u{2702} ");
assert_eq!(item.label(), "Cut ");
}
#[test]
fn new_sets_default_active_pane_to_left() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert_eq!(app.active, Pane::Left);
}
#[test]
fn new_clipboard_is_empty() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(app.clipboard.is_none());
}
#[test]
fn new_modal_is_none() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(app.modal.is_none());
}
#[test]
fn new_selected_is_none() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(app.selected.is_none());
}
#[test]
fn new_status_msg_is_empty() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(app.status_msg.is_empty());
}
#[test]
fn theme_name_returns_str_for_idx_zero() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.theme_name().is_empty());
}
#[test]
fn theme_name_matches_preset_catalogue() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
let expected = app.themes[app.theme_idx].0;
assert_eq!(app.theme_name(), expected);
}
#[test]
fn theme_desc_returns_non_empty_string() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.theme_desc().is_empty());
}
#[test]
fn theme_desc_matches_preset_catalogue() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
let expected = app.themes[app.theme_idx].1;
assert_eq!(app.theme_desc(), expected);
}
#[test]
fn theme_returns_correct_preset_object() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.theme_idx = 2;
let expected = &app.themes[2].2;
assert_eq!(app.theme(), expected);
}
#[test]
fn theme_name_and_desc_change_together_with_idx() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.theme_idx = 1;
assert_eq!(app.theme_name(), app.themes[1].0);
assert_eq!(app.theme_desc(), app.themes[1].1);
}
#[test]
fn next_theme_increments_idx() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
let initial = app.theme_idx;
app.next_theme();
assert_eq!(app.theme_idx, initial + 1);
}
#[test]
fn next_theme_wraps_around() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
let total = app.themes.len();
app.theme_idx = total - 1;
app.next_theme();
assert_eq!(app.theme_idx, 0);
}
#[test]
fn prev_theme_decrements_idx() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.theme_idx = 3;
app.prev_theme();
assert_eq!(app.theme_idx, 2);
}
#[test]
fn prev_theme_wraps_around() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.theme_idx = 0;
app.prev_theme();
assert_eq!(app.theme_idx, app.themes.len() - 1);
}
#[test]
fn new_single_pane_false_by_default() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.single_pane);
}
#[test]
fn new_show_theme_panel_false_by_default() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.show_theme_panel);
}
#[test]
fn new_single_pane_true_when_requested() {
let dir = tempdir().expect("tempdir");
let app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
single_pane: true,
..AppOptions::default()
});
assert!(app.single_pane);
}
#[test]
fn new_show_theme_panel_true_when_requested() {
let dir = tempdir().expect("tempdir");
let app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
show_theme_panel: true,
..AppOptions::default()
});
assert!(app.show_theme_panel);
}
#[test]
fn active_pane_returns_left_by_default() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert_eq!(app.active_pane().current_dir, app.left.current_dir);
}
#[test]
fn active_pane_returns_right_when_switched() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.active = Pane::Right;
assert_eq!(app.active_pane().current_dir, app.right.current_dir);
}
#[test]
fn yank_copy_populates_clipboard_with_copy_op() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), b"hi").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Copy);
let clip = app.clipboard.expect("clipboard should be set");
assert_eq!(clip.op, ClipOp::Copy);
}
#[test]
fn yank_cut_populates_clipboard_with_cut_op() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), b"hi").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Cut);
let clip = app.clipboard.expect("clipboard should be set");
assert_eq!(clip.op, ClipOp::Cut);
}
#[test]
fn yank_sets_status_message() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("file.txt"), b"hi").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Copy);
assert!(!app.status_msg.is_empty());
}
#[test]
fn yank_copy_status_mentions_copied_and_filename() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("report.txt"), b"data").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Copy);
assert!(
app.status_msg.contains("Copied"),
"status should mention 'Copied', got: {}",
app.status_msg
);
assert!(
app.status_msg.contains("report.txt"),
"status should mention the filename, got: {}",
app.status_msg
);
}
#[test]
fn yank_cut_status_mentions_cut_and_filename() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("move_me.txt"), b"data").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Cut);
assert!(
app.status_msg.contains("Cut"),
"status should mention 'Cut', got: {}",
app.status_msg
);
assert!(
app.status_msg.contains("move_me.txt"),
"status should mention the filename, got: {}",
app.status_msg
);
}
#[test]
fn yank_on_empty_dir_does_not_set_clipboard() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Copy);
assert!(app.clipboard.is_none());
}
#[test]
fn paste_with_empty_clipboard_sets_status() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.paste();
assert_eq!(app.status_msg, "Nothing in clipboard.");
}
#[test]
fn paste_copy_creates_file_in_destination() {
let src_dir = tempdir().expect("src tempdir");
let dst_dir = tempdir().expect("dst tempdir");
fs::write(src_dir.path().join("hello.txt"), b"world").expect("write");
let mut app = App::new(AppOptions {
left_dir: src_dir.path().to_path_buf(),
right_dir: src_dir.path().to_path_buf(),
..AppOptions::default()
});
app.yank(ClipOp::Copy);
app.active = Pane::Right;
app.right.navigate_to(dst_dir.path().to_path_buf());
app.paste();
assert!(dst_dir.path().join("hello.txt").exists());
assert!(src_dir.path().join("hello.txt").exists());
}
#[test]
fn paste_cut_moves_file_and_clears_clipboard() {
let src_dir = tempdir().expect("src tempdir");
let dst_dir = tempdir().expect("dst tempdir");
fs::write(src_dir.path().join("move_me.txt"), b"data").expect("write");
let mut app = App::new(AppOptions {
left_dir: src_dir.path().to_path_buf(),
right_dir: src_dir.path().to_path_buf(),
..AppOptions::default()
});
app.yank(ClipOp::Cut);
app.active = Pane::Right;
app.right.navigate_to(dst_dir.path().to_path_buf());
app.paste();
assert!(dst_dir.path().join("move_me.txt").exists());
assert!(!src_dir.path().join("move_me.txt").exists());
assert!(
app.clipboard.is_none(),
"clipboard should be cleared after cut-paste"
);
}
#[test]
fn paste_same_dir_cut_is_skipped() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("same.txt"), b"x").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.yank(ClipOp::Cut);
app.paste();
assert_eq!(
app.status_msg,
"Source and destination are the same — skipped."
);
}
#[test]
fn paste_existing_dst_raises_overwrite_modal() {
let src_dir = tempdir().expect("src tempdir");
let dst_dir = tempdir().expect("dst tempdir");
fs::write(src_dir.path().join("clash.txt"), b"src").expect("write src");
fs::write(dst_dir.path().join("clash.txt"), b"dst").expect("write dst");
let mut app = App::new(AppOptions {
left_dir: src_dir.path().to_path_buf(),
right_dir: src_dir.path().to_path_buf(),
..AppOptions::default()
});
app.yank(ClipOp::Copy);
app.active = Pane::Right;
app.right.navigate_to(dst_dir.path().to_path_buf());
app.paste();
assert!(
matches!(app.modal, Some(Modal::Overwrite { .. })),
"expected Overwrite modal"
);
}
#[test]
fn do_paste_copy_file_succeeds() {
let dir = tempdir().expect("tempdir");
let src = dir.path().join("orig.txt");
let dst = dir.path().join("copy.txt");
fs::write(&src, b"content").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.do_paste(&src, &dst, false);
assert!(dst.exists());
assert!(src.exists());
assert!(app.status_msg.contains("Pasted"));
}
#[test]
fn do_paste_cut_file_removes_source() {
let dir = tempdir().expect("tempdir");
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, b"content").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.clipboard = Some(ClipboardItem {
path: src.clone(),
op: ClipOp::Cut,
});
app.do_paste(&src, &dst, true);
assert!(dst.exists());
assert!(!src.exists());
assert!(app.clipboard.is_none());
assert!(app.status_msg.contains("Moved"));
}
#[test]
fn do_paste_copy_dir_recursively() {
let dir = tempdir().expect("tempdir");
let src = dir.path().join("src_dir");
fs::create_dir(&src).expect("mkdir src");
fs::write(src.join("nested.txt"), b"hello").expect("write nested");
let dst = dir.path().join("dst_dir");
let mut app = make_app(dir.path().to_path_buf());
app.do_paste(&src, &dst, false);
assert!(dst.join("nested.txt").exists());
assert!(src.exists(), "source dir should survive a copy");
}
#[test]
fn do_paste_error_sets_error_status() {
let dir = tempdir().expect("tempdir");
let src = dir.path().join("ghost.txt");
let dst = dir.path().join("out.txt");
let mut app = make_app(dir.path().to_path_buf());
app.do_paste(&src, &dst, false);
assert!(app.status_msg.starts_with("Error"));
}
#[test]
fn prompt_delete_raises_modal_when_entry_exists() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("del.txt"), b"bye").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.prompt_delete();
assert!(
matches!(app.modal, Some(Modal::Delete { .. })),
"expected Delete modal"
);
}
#[test]
fn prompt_delete_on_empty_dir_does_not_set_modal() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.prompt_delete();
assert!(app.modal.is_none());
}
#[test]
fn confirm_delete_removes_file_and_updates_status() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("gone.txt");
fs::write(&path, b"delete me").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete(&path);
assert!(!path.exists());
assert!(app.status_msg.contains("Deleted"));
}
#[test]
fn confirm_delete_removes_directory_recursively() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("subdir");
fs::create_dir(&sub).expect("mkdir");
fs::write(sub.join("inner.txt"), b"x").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete(&sub);
assert!(!sub.exists());
}
#[test]
fn confirm_delete_nonexistent_path_sets_error_status() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("not_here.txt");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete(&path);
assert!(app.status_msg.starts_with("Delete failed"));
}
#[test]
fn status_msg_is_cleared_by_do_paste_on_success() {
let src_dir = tempdir().expect("src tempdir");
let dst_dir = tempdir().expect("dst tempdir");
fs::write(src_dir.path().join("a.txt"), b"x").expect("write");
let mut app = App::new(AppOptions {
left_dir: src_dir.path().to_path_buf(),
right_dir: src_dir.path().to_path_buf(),
..AppOptions::default()
});
app.status_msg = "old message".into();
let src = src_dir.path().join("a.txt");
let dst = dst_dir.path().join("a.txt");
app.do_paste(&src, &dst, false);
assert_ne!(app.status_msg, "old message");
assert!(app.status_msg.contains("Pasted"));
}
#[test]
fn status_msg_starts_with_error_on_failed_paste() {
let dir = tempdir().expect("tempdir");
let src = dir.path().join("ghost.txt"); let dst = dir.path().join("out.txt");
let mut app = make_app(dir.path().to_path_buf());
app.do_paste(&src, &dst, false);
assert!(
app.status_msg.starts_with("Error"),
"expected error prefix, got: {}",
app.status_msg
);
}
#[test]
fn paste_clipboard_path_with_no_filename_sets_status() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.clipboard = Some(ClipboardItem {
path: PathBuf::from("/"),
op: ClipOp::Copy,
});
app.paste();
assert_eq!(
app.status_msg,
"Cannot paste: clipboard path has no filename."
);
}
#[test]
fn confirm_delete_reloads_both_panes() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("vanish.txt");
fs::write(&file, b"bye").expect("write");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete(&file);
let in_left = app.left.entries.iter().any(|e| e.name == "vanish.txt");
let in_right = app.right.entries.iter().any(|e| e.name == "vanish.txt");
assert!(!in_left, "file still appears in left pane after delete");
assert!(!in_right, "file still appears in right pane after delete");
}
#[test]
fn do_paste_reloads_both_panes() {
let src_dir = tempdir().expect("src tempdir");
let dst_dir = tempdir().expect("dst tempdir");
fs::write(src_dir.path().join("appear.txt"), b"hi").expect("write");
let mut app = App::new(AppOptions {
left_dir: dst_dir.path().to_path_buf(),
right_dir: dst_dir.path().to_path_buf(),
..AppOptions::default()
});
let src = src_dir.path().join("appear.txt");
let dst = dst_dir.path().join("appear.txt");
app.do_paste(&src, &dst, false);
let in_left = app.left.entries.iter().any(|e| e.name == "appear.txt");
let in_right = app.right.entries.iter().any(|e| e.name == "appear.txt");
assert!(in_left, "pasted file should appear in left pane");
assert!(in_right, "pasted file should appear in right pane");
}
#[test]
fn space_mark_adds_entry_to_marked_set() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.left.toggle_mark();
assert_eq!(app.left.marked.len(), 1);
}
#[test]
fn space_mark_toggles_off_when_already_marked() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.left.toggle_mark(); app.left.cursor = 0; app.left.toggle_mark(); assert!(app.left.marked.is_empty(), "second toggle should unmark");
}
#[test]
fn space_mark_advances_cursor_down() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
let mut app = make_app(dir.path().to_path_buf());
let before = app.left.cursor;
app.left.toggle_mark();
assert!(
app.left.cursor > before || app.left.entries.len() == 1,
"cursor should advance after marking"
);
}
#[test]
fn prompt_delete_with_marks_raises_multi_delete_modal() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("b.txt"), b"b").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.left.toggle_mark();
app.left.toggle_mark();
assert_eq!(app.left.marked.len(), 2, "both files should be marked");
app.prompt_delete();
match &app.modal {
Some(Modal::MultiDelete { paths }) => {
assert_eq!(paths.len(), 2, "modal should list 2 paths");
}
other => panic!("expected MultiDelete, got {other:?}"),
}
}
#[test]
fn prompt_delete_without_marks_raises_single_delete_modal() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), b"a").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.prompt_delete();
assert!(
matches!(app.modal, Some(Modal::Delete { .. })),
"expected Delete when nothing is marked"
);
}
#[test]
fn confirm_delete_many_removes_all_files() {
let dir = tempdir().expect("tempdir");
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
fs::write(&a, b"a").unwrap();
fs::write(&b, b"b").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete_many(&[a.clone(), b.clone()]);
assert!(!a.exists(), "a.txt should be deleted");
assert!(!b.exists(), "b.txt should be deleted");
}
#[test]
fn confirm_delete_many_sets_success_status() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("x.txt"), b"x").unwrap();
fs::write(dir.path().join("y.txt"), b"y").unwrap();
let x = dir.path().join("x.txt");
let y = dir.path().join("y.txt");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete_many(&[x, y]);
assert!(
app.status_msg.contains('2'),
"status should mention the count: {}",
app.status_msg
);
}
#[test]
fn confirm_delete_many_reloads_both_panes() {
let dir = tempdir().expect("tempdir");
let f = dir.path().join("gone.txt");
fs::write(&f, b"bye").unwrap();
let mut app = make_app(dir.path().to_path_buf());
let before_left = app.left.entries.iter().any(|e| e.name == "gone.txt");
assert!(before_left, "file should be visible before delete");
app.confirm_delete_many(&[f]);
let in_left = app.left.entries.iter().any(|e| e.name == "gone.txt");
let in_right = app.right.entries.iter().any(|e| e.name == "gone.txt");
assert!(!in_left, "deleted file should not appear in left pane");
assert!(!in_right, "deleted file should not appear in right pane");
}
#[test]
fn confirm_delete_many_clears_marks_on_both_panes() {
let dir = tempdir().expect("tempdir");
let f = dir.path().join("marked.txt");
fs::write(&f, b"data").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.left.toggle_mark();
app.right.toggle_mark();
assert!(!app.left.marked.is_empty(), "left pane should have a mark");
assert!(
!app.right.marked.is_empty(),
"right pane should have a mark"
);
app.confirm_delete_many(&[f]);
assert!(
app.left.marked.is_empty(),
"left marks should be cleared after multi-delete"
);
assert!(
app.right.marked.is_empty(),
"right marks should be cleared after multi-delete"
);
}
#[test]
fn confirm_delete_many_partial_error_reports_both_counts() {
let dir = tempdir().expect("tempdir");
let real = dir.path().join("real.txt");
fs::write(&real, b"exists").unwrap();
let ghost = dir.path().join("ghost.txt");
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete_many(&[real, ghost]);
assert!(
app.status_msg.contains('1'),
"should report 1 deleted: {}",
app.status_msg
);
assert!(
app.status_msg.contains("error"),
"should report an error: {}",
app.status_msg
);
}
#[test]
fn confirm_delete_many_removes_directory_recursively() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("subdir");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("inner.txt"), b"inner").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.confirm_delete_many(std::slice::from_ref(&sub));
assert!(!sub.exists(), "subdirectory should be removed recursively");
}
#[test]
fn multi_delete_cancelled_sets_status_and_no_files_deleted() {
let dir = tempdir().expect("tempdir");
let f = dir.path().join("keep.txt");
fs::write(&f, b"keep").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.modal = Some(Modal::MultiDelete {
paths: vec![f.clone()],
});
app.modal = None;
app.status_msg = "Multi-delete cancelled.".into();
assert!(f.exists(), "file should still exist after cancellation");
assert_eq!(app.status_msg, "Multi-delete cancelled.");
}
#[test]
fn marks_cleared_on_ascend() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("file.txt"), b"x").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.left.navigate_to(sub.clone());
app.left.toggle_mark();
assert!(
!app.left.marked.is_empty(),
"should have a mark before ascend"
);
app.left.navigate_to(dir.path().to_path_buf());
app.left.clear_marks();
assert!(
app.left.marked.is_empty(),
"marks should be clear after clear_marks"
);
}
#[test]
fn marks_cleared_on_directory_descend() {
let dir = tempdir().expect("tempdir");
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
let mut app = make_app(dir.path().to_path_buf());
if let Some(idx) = app.left.entries.iter().position(|e| e.name == "sub") {
app.left.cursor = idx;
}
app.left.toggle_mark();
assert!(
!app.left.marked.is_empty(),
"should have a mark before descend"
);
app.left.navigate_to(sub);
app.left.clear_marks();
assert!(
app.left.marked.is_empty(),
"marks should be cleared on descent"
);
}
#[test]
fn prompt_delete_with_marks_paths_are_sorted() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("z.txt"), b"z").unwrap();
fs::write(dir.path().join("a.txt"), b"a").unwrap();
fs::write(dir.path().join("m.txt"), b"m").unwrap();
let mut app = make_app(dir.path().to_path_buf());
for _ in 0..app.left.entries.len() {
app.left.toggle_mark();
}
app.prompt_delete();
if let Some(Modal::MultiDelete { paths }) = &app.modal {
let names: Vec<_> = paths
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "paths in modal should be sorted");
} else {
panic!("expected MultiDelete modal");
}
}
}