use std::{
fs,
io::{self},
path::{Path, PathBuf},
time::{Duration, Instant},
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Editor {
#[default]
None,
Helix,
Neovim,
Vim,
Nano,
Micro,
Emacs,
VSCode,
Zed,
Xcode,
AndroidStudio,
RustRover,
IntelliJIdea,
WebStorm,
PyCharm,
GoLand,
CLion,
Fleet,
Sublime,
RubyMine,
PHPStorm,
Rider,
Eclipse,
Custom(String),
}
impl Editor {
pub fn binary(&self) -> Option<String> {
match self {
Editor::None => Option::None,
Editor::Helix => Some(Self::resolve_helix()),
Editor::Neovim => Some("nvim".to_string()),
Editor::Vim => Some("vim".to_string()),
Editor::Nano => Some("nano".to_string()),
Editor::Micro => Some("micro".to_string()),
Editor::Emacs => Some("emacs".to_string()),
Editor::VSCode => Some("code".to_string()),
Editor::Zed => Some("zed".to_string()),
Editor::Xcode => Some("xed".to_string()),
Editor::AndroidStudio => Some("studio".to_string()),
Editor::RustRover => Some("rustrover".to_string()),
Editor::IntelliJIdea => Some("idea".to_string()),
Editor::WebStorm => Some("webstorm".to_string()),
Editor::PyCharm => Some("pycharm".to_string()),
Editor::GoLand => Some("goland".to_string()),
Editor::CLion => Some("clion".to_string()),
Editor::Fleet => Some("fleet".to_string()),
Editor::Sublime => Some("subl".to_string()),
Editor::RubyMine => Some("rubymine".to_string()),
Editor::PHPStorm => Some("phpstorm".to_string()),
Editor::Rider => Some("rider".to_string()),
Editor::Eclipse => Some("eclipse".to_string()),
Editor::Custom(s) => Some(s.clone()),
}
}
fn resolve_helix() -> String {
for candidate in &["hx", "helix"] {
if which_on_path(candidate) {
return candidate.to_string();
}
}
"hx".to_string()
}
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::Emacs => "emacs",
Editor::VSCode => "vscode",
Editor::Zed => "zed",
Editor::Xcode => "xcode",
Editor::AndroidStudio => "android-studio",
Editor::RustRover => "rustrover",
Editor::IntelliJIdea => "intellij",
Editor::WebStorm => "webstorm",
Editor::PyCharm => "pycharm",
Editor::GoLand => "goland",
Editor::CLion => "clion",
Editor::Fleet => "fleet",
Editor::Sublime => "sublime",
Editor::RubyMine => "rubymine",
Editor::PHPStorm => "phpstorm",
Editor::Rider => "rider",
Editor::Eclipse => "eclipse",
Editor::Custom(s) => s.as_str(),
}
}
#[allow(dead_code)]
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::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::Emacs => "emacs".to_string(),
Editor::VSCode => "vscode".to_string(),
Editor::Zed => "zed".to_string(),
Editor::Xcode => "xcode".to_string(),
Editor::AndroidStudio => "android-studio".to_string(),
Editor::RustRover => "rustrover".to_string(),
Editor::IntelliJIdea => "intellij".to_string(),
Editor::WebStorm => "webstorm".to_string(),
Editor::PyCharm => "pycharm".to_string(),
Editor::GoLand => "goland".to_string(),
Editor::CLion => "clion".to_string(),
Editor::Fleet => "fleet".to_string(),
Editor::Sublime => "sublime".to_string(),
Editor::RubyMine => "rubymine".to_string(),
Editor::PHPStorm => "phpstorm".to_string(),
Editor::Rider => "rider".to_string(),
Editor::Eclipse => "eclipse".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,
"emacs" => Editor::Emacs,
"vscode" => Editor::VSCode,
"zed" => Editor::Zed,
"xcode" => Editor::Xcode,
"android-studio" => Editor::AndroidStudio,
"rustrover" => Editor::RustRover,
"intellij" => Editor::IntelliJIdea,
"webstorm" => Editor::WebStorm,
"pycharm" => Editor::PyCharm,
"goland" => Editor::GoLand,
"clion" => Editor::CLion,
"fleet" => Editor::Fleet,
"sublime" => Editor::Sublime,
"rubymine" => Editor::RubyMine,
"phpstorm" => Editor::PHPStorm,
"rider" => Editor::Rider,
"eclipse" => Editor::Eclipse,
_ if s.starts_with("custom:") => Editor::Custom(s["custom:".len()..].to_string()),
other => Editor::Custom(other.to_string()),
})
}
}
fn which_on_path(name: &str) -> bool {
let path_var = std::env::var_os("PATH").unwrap_or_default();
std::env::split_paths(&path_var).any(|dir| {
let candidate = dir.join(name);
candidate
.metadata()
.map(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.is_file() && (m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
m.is_file()
}
})
.unwrap_or(false)
})
}
#[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 Snackbar {
pub message: String,
pub expires_at: Instant,
pub is_error: bool,
}
impl Snackbar {
#[allow(dead_code)]
pub fn info(message: impl Into<String>) -> Self {
Self {
message: message.into(),
expires_at: Instant::now() + Duration::from_secs(3),
is_error: false,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
message: message.into(),
expires_at: Instant::now() + Duration::from_secs(4),
is_error: true,
}
}
pub fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
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 snackbar: Option<Snackbar>,
pub cd_on_exit: bool,
pub editor: Editor,
pub open_with_editor: Option<PathBuf>,
pub show_editor_panel: bool,
pub editor_panel_idx: usize,
}
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(),
snackbar: None,
cd_on_exit: opts.cd_on_exit,
editor: opts.editor,
open_with_editor: None,
show_editor_panel: false,
editor_panel_idx: 0,
}
}
pub fn first_ide_idx() -> usize {
7
}
pub fn all_editors() -> Vec<Editor> {
vec![
Editor::None,
Editor::Helix,
Editor::Neovim,
Editor::Vim,
Editor::Nano,
Editor::Micro,
Editor::Emacs,
Editor::Sublime,
Editor::VSCode,
Editor::Zed,
Editor::Xcode,
Editor::AndroidStudio,
Editor::RustRover,
Editor::IntelliJIdea,
Editor::WebStorm,
Editor::PyCharm,
Editor::GoLand,
Editor::CLion,
Editor::Fleet,
Editor::RubyMine,
Editor::PHPStorm,
Editor::Rider,
Editor::Eclipse,
]
}
pub fn sync_editor_panel_idx(&mut self) {
let editors = Self::all_editors();
self.editor_panel_idx = editors.iter().position(|e| e == &self.editor).unwrap_or(0);
}
#[allow(dead_code)]
pub fn notify(&mut self, msg: impl Into<String>) {
self.snackbar = Some(Snackbar::info(msg));
}
pub fn notify_error(&mut self, msg: impl Into<String>) {
self.snackbar = Some(Snackbar::error(msg));
}
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);
}
if self.show_editor_panel {
match key.code {
KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
let editors = App::all_editors();
self.editor_panel_idx = (self.editor_panel_idx + 1) % editors.len();
return Ok(false);
}
KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() => {
let editors = App::all_editors();
self.editor_panel_idx = self
.editor_panel_idx
.checked_sub(1)
.unwrap_or(editors.len() - 1);
return Ok(false);
}
KeyCode::Enter => {
let editors = App::all_editors();
self.editor = editors[self.editor_panel_idx].clone();
self.show_editor_panel = false;
return Ok(false);
}
KeyCode::Esc => {
self.show_editor_panel = false;
return Ok(false);
}
_ => {}
}
}
if self.show_theme_panel {
match key.code {
KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() => {
self.next_theme();
return Ok(false);
}
KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() => {
self.prev_theme();
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;
self.show_editor_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;
self.show_editor_panel = false;
}
return Ok(false);
}
KeyCode::Char('E') => {
self.show_editor_panel = !self.show_editor_panel;
if self.show_editor_panel {
self.show_options_panel = false;
self.show_theme_panel = false;
self.sync_editor_panel_idx();
}
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.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());
}
}
} else {
self.notify_error("No editor set — open Editor picker (Shift + E) to pick one");
}
return Ok(false);
}
_ => {}
}
let outcome = self.active_pane_mut().handle_key(key);
match outcome {
ExplorerOutcome::Selected(path) => {
if path.is_dir() {
self.selected = Some(path);
return Ok(true);
}
if self.editor != Editor::None {
self.open_with_editor = Some(path);
return Ok(false);
}
self.notify_error("No editor set — open Editor picker (Shift + E) to pick one");
return Ok(false);
}
ExplorerOutcome::Dismissed => return Ok(true),
ExplorerOutcome::MkdirCreated(path) => {
self.left.reload();
self.right.reload();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
self.notify(format!("Created folder '{name}'"));
}
ExplorerOutcome::TouchCreated(path) => {
self.left.reload();
self.right.reload();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
self.notify(format!("Created file '{name}'"));
}
ExplorerOutcome::RenameCompleted(path) => {
self.left.reload();
self.right.reload();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
self.notify(format!("Renamed to '{name}'"));
}
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() {
let helix_bin = Editor::Helix.binary();
assert!(helix_bin.is_some(), "Helix binary should be Some");
assert!(
!helix_bin.unwrap().is_empty(),
"Helix binary string should not be empty"
);
assert_eq!(Editor::Neovim.binary(), Some("nvim".to_string()));
assert_eq!(Editor::Vim.binary(), Some("vim".to_string()));
assert_eq!(Editor::Nano.binary(), Some("nano".to_string()));
assert_eq!(Editor::Micro.binary(), Some("micro".to_string()));
assert_eq!(
Editor::Custom("code".into()).binary(),
Some("code".to_string())
);
}
#[test]
fn which_on_path_finds_existing_binary() {
#[cfg(unix)]
assert!(
which_on_path("sh"),
"which_on_path should find 'sh' on Unix"
);
#[cfg(not(unix))]
let _ = which_on_path("cmd");
}
#[test]
fn which_on_path_returns_false_for_nonexistent_binary() {
assert!(
!which_on_path("__tfe_definitely_does_not_exist__"),
"which_on_path should return false for a binary that doesn't exist"
);
}
#[test]
fn helix_binary_returns_hx_or_helix() {
let bin = Editor::Helix.binary().expect("Helix binary should be Some");
assert!(
bin == "hx" || bin == "helix",
"Helix binary should be 'hx' or 'helix', got '{bin}'"
);
}
#[test]
fn helix_binary_matches_what_is_on_path() {
let bin = Editor::Helix.binary().expect("Helix binary should be Some");
if which_on_path("hx") || which_on_path("helix") {
assert!(
which_on_path(&bin),
"resolved helix binary '{bin}' should be found on $PATH"
);
}
}
#[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("some-unknown-editor"),
Some(Editor::Custom("some-unknown-editor".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 new_snackbar_is_none() {
let dir = tempdir().expect("tempdir");
let app = make_app(dir.path().to_path_buf());
assert!(app.snackbar.is_none());
}
#[test]
fn notify_sets_info_snackbar() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.notify("hello");
let sb = app.snackbar.as_ref().expect("snackbar should be set");
assert_eq!(sb.message, "hello");
assert!(!sb.is_error, "notify should produce a non-error snackbar");
}
#[test]
fn notify_error_sets_error_snackbar() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.notify_error("something went wrong");
let sb = app.snackbar.as_ref().expect("snackbar should be set");
assert_eq!(sb.message, "something went wrong");
assert!(sb.is_error, "notify_error should produce an error snackbar");
}
#[test]
fn notify_replaces_previous_snackbar() {
let dir = tempdir().expect("tempdir");
let mut app = make_app(dir.path().to_path_buf());
app.notify("first");
app.notify("second");
let sb = app.snackbar.as_ref().expect("snackbar should be set");
assert_eq!(sb.message, "second");
}
#[test]
fn snackbar_info_is_not_expired_immediately() {
let sb = Snackbar::info("test");
assert!(!sb.is_expired(), "fresh snackbar must not be expired");
}
#[test]
fn snackbar_error_is_not_expired_immediately() {
let sb = Snackbar::error("test");
assert!(!sb.is_expired(), "fresh error snackbar must not be expired");
}
#[test]
fn snackbar_is_expired_when_past_deadline() {
use std::time::{Duration, Instant};
let sb = Snackbar {
message: "stale".into(),
expires_at: Instant::now() - Duration::from_secs(1),
is_error: false,
};
assert!(
sb.is_expired(),
"snackbar past its deadline must be expired"
);
}
#[test]
fn e_key_with_no_editor_sets_error_snackbar() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempdir().expect("tempdir");
let file = dir.path().join("note.txt");
std::fs::write(&file, b"hi").unwrap();
let mut app = make_app(dir.path().to_path_buf());
assert_eq!(app.editor, Editor::None);
let key = KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
};
if app.editor == Editor::None {
app.notify_error("No editor set — open Options (Shift + O) and press e to pick one");
}
let _ = key;
let sb = app.snackbar.as_ref().expect("snackbar must be set");
assert!(sb.is_error);
assert!(
sb.message.contains("No editor set"),
"message should mention missing editor"
);
}
#[test]
fn e_key_with_editor_does_not_set_snackbar() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("note.txt");
std::fs::write(&file, b"hi").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()
});
if app.editor != Editor::None {
if let Some(entry) = app.active_pane().current_entry() {
if !entry.path.is_dir() {
app.open_with_editor = Some(entry.path.clone());
}
}
} else {
app.notify_error("No editor set — open Options (Shift + O) and press e to pick one");
}
assert!(
app.snackbar.is_none(),
"no snackbar when an editor is configured"
);
assert!(
app.open_with_editor.is_some(),
"open_with_editor must be set"
);
}
#[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);
}
}