use std::{
fs,
io::{self},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Editor {
#[default]
None,
Helix,
Neovim,
Vim,
Nano,
Micro,
Custom(String),
}
impl Editor {
pub fn binary(&self) -> Option<&str> {
match self {
Editor::None => Option::None,
Editor::Helix => Some("hx"),
Editor::Neovim => Some("nvim"),
Editor::Vim => Some("vim"),
Editor::Nano => Some("nano"),
Editor::Micro => Some("micro"),
Editor::Custom(s) => Some(s.as_str()),
}
}
pub fn label(&self) -> &str {
match self {
Editor::None => "none",
Editor::Helix => "helix",
Editor::Neovim => "nvim",
Editor::Vim => "vim",
Editor::Nano => "nano",
Editor::Micro => "micro",
Editor::Custom(s) => s.as_str(),
}
}
pub fn cycle(&self) -> Editor {
match self {
Editor::None => Editor::Helix,
Editor::Helix => Editor::Neovim,
Editor::Neovim => Editor::Vim,
Editor::Vim => Editor::Nano,
Editor::Nano => Editor::Micro,
Editor::Micro => Editor::None,
Editor::Custom(_) => Editor::None,
}
}
pub fn to_key(&self) -> String {
match self {
Editor::None => "none".to_string(),
Editor::Helix => "helix".to_string(),
Editor::Neovim => "nvim".to_string(),
Editor::Vim => "vim".to_string(),
Editor::Nano => "nano".to_string(),
Editor::Micro => "micro".to_string(),
Editor::Custom(s) => format!("custom:{s}"),
}
}
pub fn from_key(s: &str) -> Option<Editor> {
if s.is_empty() {
return Option::None;
}
Some(match s {
"none" => Editor::None,
"helix" => Editor::Helix,
"nvim" => Editor::Neovim,
"vim" => Editor::Vim,
"nano" => Editor::Nano,
"micro" => Editor::Micro,
_ if s.starts_with("custom:") => Editor::Custom(s["custom:".len()..].to_string()),
other => Editor::Custom(other.to_string()),
})
}
}
#[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,
pub cd_on_exit: bool,
pub editor: Editor,
}
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(),
cd_on_exit: false,
editor: Editor::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 show_options_panel: bool,
pub single_pane: bool,
pub modal: Option<Modal>,
pub selected: Option<PathBuf>,
pub status_msg: String,
pub cd_on_exit: bool,
pub editor: Editor,
pub open_with_editor: Option<PathBuf>,
}
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,
show_options_panel: false,
single_pane: opts.single_pane,
modal: None,
selected: None,
status_msg: String::new(),
cd_on_exit: opts.cd_on_exit,
editor: opts.editor,
open_with_editor: None,
}
}
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;
if self.show_theme_panel {
self.show_options_panel = false;
}
return Ok(false);
}
KeyCode::Char('O') => {
self.show_options_panel = !self.show_options_panel;
if self.show_options_panel {
self.show_theme_panel = false;
}
return Ok(false);
}
KeyCode::Char('C') => {
self.cd_on_exit = !self.cd_on_exit;
let state = if self.cd_on_exit { "on" } else { "off" };
self.status_msg = format!("cd-on-exit: {state}");
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);
}
KeyCode::Char('e') if key.modifiers.is_empty() => {
if self.show_options_panel {
self.editor = self.editor.cycle();
self.status_msg = format!("Editor: {}", self.editor.label());
} else if self.editor != Editor::None {
if let Some(entry) = self.active_pane().current_entry() {
if !entry.path.is_dir() {
self.open_with_editor = Some(entry.path.clone());
}
}
}
return Ok(false);
}
_ => {}
}
let outcome = self.active_pane_mut().handle_key(key);
match outcome {
ExplorerOutcome::Selected(path) => {
if self.editor != Editor::None && !path.is_dir() {
self.open_with_editor = Some(path);
return Ok(false);
}
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;
#[test]
fn editor_default_is_none() {
assert_eq!(Editor::default(), Editor::None);
}
#[test]
fn editor_binary_none_returns_option_none() {
assert_eq!(Editor::None.binary(), Option::None);
}
#[test]
fn editor_binary_names() {
assert_eq!(Editor::Helix.binary(), Some("hx"));
assert_eq!(Editor::Neovim.binary(), Some("nvim"));
assert_eq!(Editor::Vim.binary(), Some("vim"));
assert_eq!(Editor::Nano.binary(), Some("nano"));
assert_eq!(Editor::Micro.binary(), Some("micro"));
assert_eq!(Editor::Custom("code".into()).binary(), Some("code"));
}
#[test]
fn editor_label_names() {
assert_eq!(Editor::None.label(), "none");
assert_eq!(Editor::Helix.label(), "helix");
assert_eq!(Editor::Neovim.label(), "nvim");
assert_eq!(Editor::Vim.label(), "vim");
assert_eq!(Editor::Nano.label(), "nano");
assert_eq!(Editor::Micro.label(), "micro");
assert_eq!(Editor::Custom("code".into()).label(), "code");
}
#[test]
fn editor_cycle_order() {
assert_eq!(Editor::None.cycle(), Editor::Helix);
assert_eq!(Editor::Helix.cycle(), Editor::Neovim);
assert_eq!(Editor::Neovim.cycle(), Editor::Vim);
assert_eq!(Editor::Vim.cycle(), Editor::Nano);
assert_eq!(Editor::Nano.cycle(), Editor::Micro);
assert_eq!(Editor::Micro.cycle(), Editor::None);
}
#[test]
fn editor_custom_cycle_resets_to_none() {
assert_eq!(Editor::Custom("code".into()).cycle(), Editor::None);
}
#[test]
fn editor_cycle_full_loop_returns_to_start() {
let mut e = Editor::None;
for _ in 0..6 {
e = e.cycle();
}
assert_eq!(e, Editor::None);
}
#[test]
fn editor_to_key_round_trips() {
for e in [
Editor::None,
Editor::Helix,
Editor::Neovim,
Editor::Vim,
Editor::Nano,
Editor::Micro,
Editor::Custom("code".into()),
] {
let key = e.to_key();
assert_eq!(Editor::from_key(&key), Some(e));
}
}
#[test]
fn editor_none_serialises_as_none_key() {
assert_eq!(Editor::None.to_key(), "none");
assert_eq!(Editor::from_key("none"), Some(Editor::None));
}
#[test]
fn editor_from_key_empty_returns_none() {
assert_eq!(Editor::from_key(""), None);
}
#[test]
fn editor_from_key_unknown_is_custom() {
assert_eq!(
Editor::from_key("emacs"),
Some(Editor::Custom("emacs".into()))
);
}
#[test]
fn editor_from_key_custom_prefix_strips_prefix() {
assert_eq!(
Editor::from_key("custom:code"),
Some(Editor::Custom("code".into()))
);
}
#[test]
fn app_options_default_editor_is_none() {
assert_eq!(AppOptions::default().editor, Editor::None);
}
#[test]
fn app_new_editor_field_is_from_options() {
let dir = tempdir().unwrap();
let app = make_app(dir.path().to_path_buf());
assert_eq!(app.editor, Editor::None);
}
#[test]
fn app_new_open_with_editor_is_none() {
let dir = tempdir().unwrap();
let app = make_app(dir.path().to_path_buf());
assert!(app.open_with_editor.is_none());
}
#[test]
fn enter_on_file_with_editor_sets_open_with_editor_not_selected() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, b"hello").unwrap();
let mut app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
editor: Editor::Helix,
..AppOptions::default()
});
let outcome = ExplorerOutcome::Selected(file.clone());
if let ExplorerOutcome::Selected(path) = outcome {
if app.editor != Editor::None && !path.is_dir() {
app.open_with_editor = Some(path);
} else {
app.selected = Some(path);
}
}
assert_eq!(
app.open_with_editor,
Some(file),
"open_with_editor must be set"
);
assert!(
app.selected.is_none(),
"selected must remain None — TUI must not exit"
);
}
#[test]
fn enter_on_file_with_editor_none_sets_selected_and_exits() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, b"hello").unwrap();
let mut app = make_app(dir.path().to_path_buf());
assert_eq!(app.editor, Editor::None);
let outcome = ExplorerOutcome::Selected(file.clone());
if let ExplorerOutcome::Selected(path) = outcome {
if app.editor != Editor::None && !path.is_dir() {
app.open_with_editor = Some(path);
} else {
app.selected = Some(path);
}
}
assert_eq!(
app.selected,
Some(file),
"selected must be set so TUI exits"
);
assert!(
app.open_with_editor.is_none(),
"open_with_editor must remain None"
);
}
#[test]
fn enter_on_dir_always_navigates_not_opens_editor() {
let dir = tempdir().unwrap();
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let mut app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
editor: Editor::Helix,
..AppOptions::default()
});
let outcome = ExplorerOutcome::Selected(subdir.clone());
if let ExplorerOutcome::Selected(path) = outcome {
if app.editor != Editor::None && !path.is_dir() {
app.open_with_editor = Some(path);
} else {
app.selected = Some(path);
}
}
assert!(
app.open_with_editor.is_none(),
"dirs must never go to open_with_editor"
);
assert_eq!(app.selected, Some(subdir));
}
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 new_show_options_panel_false_by_default() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.show_options_panel);
}
#[test]
fn new_cd_on_exit_false_by_default() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.cd_on_exit);
}
#[test]
fn new_cd_on_exit_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(),
cd_on_exit: true,
..AppOptions::default()
});
assert!(app.cd_on_exit);
}
#[test]
fn capital_o_opens_options_panel() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
assert!(!app.show_options_panel);
app.show_options_panel = true;
assert!(app.show_options_panel);
}
#[test]
fn capital_o_closes_options_panel_when_already_open() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.show_options_panel = true;
app.show_options_panel = !app.show_options_panel;
assert!(!app.show_options_panel);
}
#[test]
fn opening_options_panel_closes_theme_panel() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.show_theme_panel = true;
app.show_options_panel = !app.show_options_panel;
if app.show_options_panel {
app.show_theme_panel = false;
}
assert!(app.show_options_panel);
assert!(!app.show_theme_panel);
}
#[test]
fn opening_theme_panel_closes_options_panel() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.show_options_panel = true;
app.show_theme_panel = !app.show_theme_panel;
if app.show_theme_panel {
app.show_options_panel = false;
}
assert!(app.show_theme_panel);
assert!(!app.show_options_panel);
}
#[test]
fn capital_c_toggles_cd_on_exit_on() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
assert!(!app.cd_on_exit);
app.cd_on_exit = !app.cd_on_exit;
assert!(app.cd_on_exit);
}
#[test]
fn capital_c_toggles_cd_on_exit_off() {
let dir = tempdir().expect("tempdir");
let mut app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
cd_on_exit: true,
..AppOptions::default()
});
app.cd_on_exit = !app.cd_on_exit;
assert!(!app.cd_on_exit);
}
#[test]
fn capital_c_sets_status_message_on() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.cd_on_exit = !app.cd_on_exit;
let state = if app.cd_on_exit { "on" } else { "off" };
app.status_msg = format!("cd-on-exit: {state}");
assert_eq!(app.status_msg, "cd-on-exit: on");
}
#[test]
fn capital_c_sets_status_message_off() {
let dir = tempdir().expect("tempdir");
let mut app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
cd_on_exit: true,
..AppOptions::default()
});
app.cd_on_exit = !app.cd_on_exit;
let state = if app.cd_on_exit { "on" } else { "off" };
app.status_msg = format!("cd-on-exit: {state}");
assert_eq!(app.status_msg, "cd-on-exit: off");
}
#[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");
}
}
#[test]
fn tab_key_switches_active_pane_from_left_to_right() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
assert_eq!(app.active, Pane::Left);
app.active = app.active.other();
assert_eq!(app.active, Pane::Right);
}
#[test]
fn tab_key_switches_active_pane_from_right_to_left() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.active = Pane::Right;
app.active = app.active.other();
assert_eq!(app.active, Pane::Left);
}
#[test]
fn tab_key_two_switches_return_to_original() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
let original = app.active;
app.active = app.active.other();
app.active = app.active.other();
assert_eq!(app.active, original);
}
#[test]
fn new_themes_list_is_non_empty() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(!app.themes.is_empty(), "themes list must not be empty");
}
#[test]
fn new_theme_idx_is_zero() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert_eq!(app.theme_idx, 0);
}
#[test]
fn new_theme_idx_from_options_is_respected() {
let dir = tempdir().expect("tempdir");
let app = App::new(AppOptions {
left_dir: dir.path().to_path_buf(),
right_dir: dir.path().to_path_buf(),
theme_idx: 2,
..AppOptions::default()
});
assert_eq!(app.theme_idx, 2);
}
#[test]
fn next_theme_never_exceeds_themes_len() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
let total = app.themes.len();
for _ in 0..total * 2 {
app.next_theme();
assert!(
app.theme_idx < total,
"theme_idx {} out of bounds (len {})",
app.theme_idx,
total
);
}
}
#[test]
fn prev_theme_never_exceeds_themes_len() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
let total = app.themes.len();
for _ in 0..total * 2 {
app.prev_theme();
assert!(
app.theme_idx < total,
"theme_idx {} out of bounds (len {})",
app.theme_idx,
total
);
}
}
#[test]
fn do_paste_copy_clears_previous_error_status() {
let dir = tempdir().expect("tempdir");
let src_file = dir.path().join("src.txt");
let dst_file = dir.path().join("dst.txt");
fs::write(&src_file, b"content").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.status_msg = "Error: something bad".into();
app.do_paste(&src_file, &dst_file, false);
assert!(
!app.status_msg.starts_with("Error"),
"successful paste must replace error status, got: {}",
app.status_msg
);
}
#[test]
fn do_paste_success_status_mentions_filename() {
let dir = tempdir().expect("tempdir");
let src_file = dir.path().join("report.txt");
let dst_file = dir.path().join("report_copy.txt");
fs::write(&src_file, b"data").unwrap();
let mut app = make_app(dir.path().to_path_buf());
app.do_paste(&src_file, &dst_file, false);
assert!(
app.status_msg.contains("report_copy.txt"),
"status should mention destination filename, got: {}",
app.status_msg
);
}
#[test]
fn inactive_pane_is_right_when_left_is_active() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert_eq!(app.active, Pane::Left);
assert_eq!(app.active.other(), Pane::Right);
}
#[test]
fn inactive_pane_is_left_when_right_is_active() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.active = Pane::Right;
assert_eq!(app.active.other(), Pane::Left);
}
#[test]
fn active_pane_mut_returns_right_when_right_is_active() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.active = Pane::Right;
let right_dir = app.right.current_dir.clone();
assert_eq!(app.active_pane_mut().current_dir, right_dir);
}
#[test]
fn active_pane_mut_returns_left_when_left_is_active() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.active = Pane::Left;
let left_dir = app.left.current_dir.clone();
assert_eq!(app.active_pane_mut().current_dir, left_dir);
}
#[test]
fn single_pane_toggle_via_field() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
assert!(!app.single_pane);
app.single_pane = !app.single_pane;
assert!(app.single_pane);
app.single_pane = !app.single_pane;
assert!(!app.single_pane);
}
#[test]
fn app_options_default_show_hidden_false() {
assert!(!AppOptions::default().show_hidden);
}
#[test]
fn app_options_default_theme_idx_zero() {
assert_eq!(AppOptions::default().theme_idx, 0);
}
#[test]
fn app_options_default_sort_mode_is_name() {
assert_eq!(AppOptions::default().sort_mode, SortMode::Name);
}
#[test]
fn app_options_default_extensions_empty() {
assert!(AppOptions::default().extensions.is_empty());
}
#[test]
fn app_options_default_single_pane_false() {
assert!(!AppOptions::default().single_pane);
}
#[test]
fn app_options_default_show_theme_panel_false() {
assert!(!AppOptions::default().show_theme_panel);
}
#[test]
fn app_options_default_cd_on_exit_false() {
assert!(!AppOptions::default().cd_on_exit);
}
}