//! Application state for the `tfe` binary.
//!
//! This module owns all runtime state that is not part of the file-explorer
//! widget itself:
//!
//! * [`Pane`]          — which of the two panes is active.
//! * [`ClipOp`]        — whether a yanked entry is being copied or cut.
//! * [`ClipboardItem`] — what is currently in the clipboard.
//! * [`Modal`]         — an optional blocking confirmation dialog.
//! * [`Editor`]        — which editor to launch when `e` is pressed on a file.
//! * [`App`]           — the top-level state struct that drives the event loop.

use std::{
    fs,
    io::{self},
    path::{Path, PathBuf},
    time::{Duration, Instant},
};

// ── Editor ────────────────────────────────────────────────────────────────────

/// The editor that is launched when the user presses `e` on a file.
///
/// # Persistence
///
/// Serialised to/from a short key string in the `tfe` state file:
///
/// | Variant            | Key string        |
/// |--------------------|-------------------|
/// | `None`             | `none`            |
/// | `Helix`            | `helix`           |
/// | `Neovim`           | `nvim`            |
/// | `Vim`              | `vim`             |
/// | `Nano`             | `nano`            |
/// | `Micro`            | `micro`           |
/// | `Emacs`            | `emacs`           |
/// | `VSCode`           | `vscode`          |
/// | `Zed`              | `zed`             |
/// | `Xcode`            | `xcode`           |
/// | `AndroidStudio`    | `android-studio`  |
/// | `RustRover`        | `rustrover`       |
/// | `IntelliJIdea`     | `intellij`        |
/// | `WebStorm`         | `webstorm`        |
/// | `PyCharm`          | `pycharm`         |
/// | `GoLand`           | `goland`          |
/// | `CLion`            | `clion`           |
/// | `Fleet`            | `fleet`           |
/// | `Sublime`          | `sublime`         |
/// | `RubyMine`         | `rubymine`        |
/// | `PHPStorm`         | `phpstorm`        |
/// | `Rider`            | `rider`           |
/// | `Eclipse`          | `eclipse`         |
/// | `Custom(s)`        | `custom:<s>`      |
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Editor {
    /// No editor — pressing `e` on a file is a silent no-op.
    #[default]
    None,
    /// [Helix](https://helix-editor.com/) — `hx`
    Helix,
    /// [Neovim](https://neovim.io/) — `nvim`
    Neovim,
    /// [Vim](https://www.vim.org/) — `vim`
    Vim,
    /// [Nano](https://www.nano-editor.org/) — `nano`
    Nano,
    /// [Micro](https://micro-editor.github.io/) — `micro`
    Micro,
    /// [Emacs](https://www.gnu.org/software/emacs/) — `emacs`
    Emacs,
    /// [Visual Studio Code](https://code.visualstudio.com/) — `code`
    VSCode,
    /// [Zed](https://zed.dev/) — `zed`
    Zed,
    /// [Xcode](https://developer.apple.com/xcode/) — `xed`
    Xcode,
    /// [Android Studio](https://developer.android.com/studio) — `studio`
    AndroidStudio,
    /// [RustRover](https://www.jetbrains.com/rust/) — `rustrover`
    RustRover,
    /// [IntelliJ IDEA](https://www.jetbrains.com/idea/) — `idea`
    IntelliJIdea,
    /// [WebStorm](https://www.jetbrains.com/webstorm/) — `webstorm`
    WebStorm,
    /// [PyCharm](https://www.jetbrains.com/pycharm/) — `pycharm`
    PyCharm,
    /// [GoLand](https://www.jetbrains.com/go/) — `goland`
    GoLand,
    /// [CLion](https://www.jetbrains.com/clion/) — `clion`
    CLion,
    /// [Fleet](https://www.jetbrains.com/fleet/) — `fleet`
    Fleet,
    /// [Sublime Text](https://www.sublimetext.com/) — `subl`
    Sublime,
    /// [RubyMine](https://www.jetbrains.com/ruby/) — `rubymine`
    RubyMine,
    /// [PHPStorm](https://www.jetbrains.com/phpstorm/) — `phpstorm`
    PHPStorm,
    /// [Rider](https://www.jetbrains.com/rider/) — `rider`
    Rider,
    /// [Eclipse](https://www.eclipse.org/) — `eclipse`
    Eclipse,
    /// A user-supplied binary name or path.
    Custom(String),
}

impl Editor {
    /// Return the launch binary (and optional arguments) for this editor.
    ///
    /// Returns `None` for `Editor::None` — the caller should skip the launch.
    ///
    /// For `Custom` variants the returned string may contain embedded
    /// arguments (e.g. `"code --wait"`).  The caller is responsible for
    /// splitting on whitespace to separate the binary from its arguments
    /// before passing them to `std::process::Command`.
    ///
    /// For `Editor::Helix` the function probes `$PATH` at call time: it
    /// tries `hx` first (the name used by the official release binaries and
    /// Homebrew on macOS), then falls back to `helix` (the name used by most
    /// Linux package managers such as pacman, apt, and dnf).  Whichever is
    /// found first is returned; if neither is on `$PATH` the string `"hx"` is
    /// returned as a best-effort fallback so the error message names a real
    /// binary.
    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()),
        }
    }

    /// Probe `$PATH` for the Helix binary name.
    ///
    /// Returns `"hx"` when found, then tries `"helix"`, and finally falls
    /// back to `"hx"` so callers always get a non-empty string.
    fn resolve_helix() -> String {
        for candidate in &["hx", "helix"] {
            if which_on_path(candidate) {
                return candidate.to_string();
            }
        }
        // Neither found — return "hx" so the error message is predictable.
        "hx".to_string()
    }

    /// Return a short human-readable label (shown in the options panel).
    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(),
        }
    }

    /// Cycle to the next editor in the fixed rotation.
    ///
    /// Order: None → Helix → Neovim → Vim → Nano → Micro → None → …
    ///
    /// `Custom` variants skip back to `None` — the user must set them via
    /// `--editor` or direct persistence editing.
    #[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,
            // New GUI/IDE editors and Custom all fall back to None in the legacy
            // cycle rotation.  The cycle() method is deprecated in favour of the
            // editor-picker panel (Shift + E); this fallback keeps it exhaustive.
            _ => Editor::None,
        }
    }

    /// Serialise to the on-disk key string.
    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}"),
        }
    }

    /// Deserialise from the on-disk key string.
    ///
    /// Returns `None` (the Rust `Option`) for an empty string; unknown values
    /// are treated as `Custom` so that third-party editors survive round-trips.
    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()),
        })
    }
}

// ── PATH probe helper ─────────────────────────────────────────────────────────

/// Returns `true` when `name` resolves to an executable on `$PATH`.
///
/// This is intentionally minimal — it only walks `$PATH` entries and checks
/// for a regular (or symlinked) file with execute permission.  It does not
/// handle Windows `.cmd` shims or `PATHEXT`, but that is fine because Helix
/// does not ship as a `.cmd` wrapper.
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);
        // `metadata` follows symlinks, so a symlink to an executable is OK.
        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)
    })
}

// ── AppOptions ────────────────────────────────────────────────────────────────

/// Startup configuration passed to [`App::new`].
///
/// Grouping all constructor parameters into a single struct keeps the call
/// sites readable and avoids the `clippy::too_many_arguments` limit.
///
/// # Example
///
/// ```rust,ignore
/// let app = App::new(AppOptions {
///     left_dir: PathBuf::from("/home/user"),
///     right_dir: PathBuf::from("/tmp"),
///     ..AppOptions::default()
/// });
/// ```
#[derive(Debug, Clone)]
pub struct AppOptions {
    /// Starting directory for the left pane.
    pub left_dir: PathBuf,
    /// Starting directory for the right pane.
    pub right_dir: PathBuf,
    /// File-extension filter (empty = show all).
    pub extensions: Vec<String>,
    /// Show hidden (dot-prefixed) entries on startup.
    pub show_hidden: bool,
    /// Index into the theme catalogue to use on startup.
    pub theme_idx: usize,
    /// Whether the theme-picker side-panel should be open on startup.
    pub show_theme_panel: bool,
    /// Whether to start in single-pane mode.
    pub single_pane: bool,
    /// Active sort mode.
    pub sort_mode: SortMode,
    /// Whether cd-on-exit is enabled.
    pub cd_on_exit: bool,
    /// Which editor to open when the user presses `e` on a file.
    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 crate::{ExplorerOutcome, FileExplorer, SortMode, Theme};
use crossterm::event::{self, Event, KeyCode, KeyModifiers};

// ── Pane ─────────────────────────────────────────────────────────────────────

/// Which of the two explorer panes is currently focused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
    Left,
    Right,
}

impl Pane {
    /// Return the opposite pane.
    pub fn other(self) -> Self {
        match self {
            Pane::Left => Pane::Right,
            Pane::Right => Pane::Left,
        }
    }
}

// ── ClipOp ───────────────────────────────────────────────────────────────────

/// Whether the clipboard item should be copied or moved on paste.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipOp {
    Copy,
    Cut,
}

// ── ClipboardItem ─────────────────────────────────────────────────────────────

/// An entry (or entries) that have been yanked (copied or cut) and are waiting
/// to be pasted.  When the user space-marks multiple files before pressing
/// `y`/`x`, all marked paths are stored here.
#[derive(Debug, Clone)]
pub struct ClipboardItem {
    /// One or more source paths waiting to be pasted.
    pub paths: Vec<PathBuf>,
    /// Whether this is a copy or a cut operation.
    pub op: ClipOp,
}

impl ClipboardItem {
    /// A small emoji that visually distinguishes copy from cut in the action bar.
    pub fn icon(&self) -> &'static str {
        match self.op {
            ClipOp::Copy => "\u{1F4CB}", // 📋
            ClipOp::Cut => "\u{2702} ",  // ✂
        }
    }

    /// A short human-readable label for the current operation.
    pub fn label(&self) -> &'static str {
        match self.op {
            ClipOp::Copy => "Copy",
            ClipOp::Cut => "Cut ",
        }
    }

    /// Number of paths in the clipboard.
    pub fn count(&self) -> usize {
        self.paths.len()
    }

    /// The first (or only) path — convenience accessor for single-item clipboard.
    pub fn first_path(&self) -> Option<&PathBuf> {
        self.paths.first()
    }
}

// ── Modal ─────────────────────────────────────────────────────────────────────

/// A blocking confirmation dialog that intercepts all keyboard input until
/// the user either confirms or cancels.
#[derive(Debug)]
pub enum Modal {
    /// Asks the user to confirm deletion of a file or directory.
    Delete {
        /// Absolute path of the entry to delete.
        path: PathBuf,
    },
    /// Asks the user to confirm deletion of multiple marked entries.
    MultiDelete {
        /// Absolute paths of all entries to delete.
        paths: Vec<PathBuf>,
    },
    /// Asks the user whether to overwrite an existing destination during paste.
    Overwrite {
        /// Absolute path of the source being pasted.
        src: PathBuf,
        /// Absolute path of the destination that already exists.
        dst: PathBuf,
        /// `true` if the original operation was a cut (move).
        is_cut: bool,
    },
}

// ── App ───────────────────────────────────────────────────────────────────────

// Top-level application state for the `tfe` binary.
//
// Owns both [`FileExplorer`] panes, the clipboard, the active modal, theme
// state, and the final selected path (set when the user confirms a file).
// ── Snackbar ──────────────────────────────────────────────────────────────────

/// A short-lived notification that floats over the UI and auto-expires.
pub struct Snackbar {
    /// The message to display.
    pub message: String,
    /// When the snackbar should stop being shown.
    pub expires_at: Instant,
    /// Whether this is an error (affects colour).
    pub is_error: bool,
}

impl Snackbar {
    /// Create a new info snackbar that lasts 3 seconds.
    #[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,
        }
    }

    /// Create a new error snackbar that lasts 4 seconds.
    pub fn error(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            expires_at: Instant::now() + Duration::from_secs(4),
            is_error: true,
        }
    }

    /// Returns `true` if the snackbar's display window has passed.
    pub fn is_expired(&self) -> bool {
        Instant::now() >= self.expires_at
    }
}

pub struct App {
    /// The left-hand explorer pane.
    pub left: FileExplorer,
    /// The right-hand explorer pane.
    pub right: FileExplorer,
    /// Which pane currently has keyboard focus.
    pub active: Pane,
    /// The most recently yanked entry, if any.
    pub clipboard: Option<ClipboardItem>,
    /// All available themes as `(name, description, Theme)` triples.
    pub themes: Vec<(&'static str, &'static str, Theme)>,
    /// Index into `themes` for the currently active theme.
    pub theme_idx: usize,
    /// Whether the theme-picker side-panel is visible.
    pub show_theme_panel: bool,
    /// Whether the options side-panel is visible.
    pub show_options_panel: bool,
    /// Whether only the active pane is shown (single-pane mode).
    pub single_pane: bool,
    /// The currently displayed confirmation modal, if any.
    pub modal: Option<Modal>,
    /// The path chosen by the user (set on `Enter` / `→` confirm).
    pub selected: Option<PathBuf>,
    /// One-line status text shown in the action bar.
    pub status_msg: String,
    /// Optional floating notification that auto-expires.
    pub snackbar: Option<Snackbar>,
    /// Whether cd-on-exit is enabled (dismiss prints cwd to stdout).
    pub cd_on_exit: bool,
    /// Which editor to open when the user presses `e` on a file.
    pub editor: Editor,
    /// When `Some`, the run-loop should suspend the TUI, open this path in
    /// `self.editor`, then restore the TUI.  Set by the `e` key handler;
    /// cleared by `run_loop` after the editor exits.
    pub open_with_editor: Option<PathBuf>,
    /// Whether the editor-picker side-panel is visible.
    pub show_editor_panel: bool,
    /// Highlighted row index in the editor-picker panel (cursor position).
    pub editor_panel_idx: usize,
}

impl App {
    /// Construct a new `App` from an [`AppOptions`] config struct.
    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,
        }
    }

    /// Index of the first IDE/GUI editor in the [`all_editors`] list.
    ///
    /// Everything before this index is a terminal editor; everything from
    /// this index onward is a GUI editor or IDE.  Used by the editor panel
    /// to render the two section headers.
    pub fn first_ide_idx() -> usize {
        // None, Helix, Neovim, Vim, Nano, Micro, Emacs  →  7 terminal entries
        7
    }

    /// Return every [`Editor`] variant in display order.
    ///
    /// Used by the editor-picker panel to populate the list and navigate it.
    /// Terminal editors come first, then GUI editors/IDEs.
    pub fn all_editors() -> Vec<Editor> {
        vec![
            // ── Terminal editors ──────────────────────────────────────────────
            Editor::None,
            Editor::Helix,
            Editor::Neovim,
            Editor::Vim,
            Editor::Nano,
            Editor::Micro,
            Editor::Emacs,
            // ── IDEs & GUI editors ────────────────────────────────────────────
            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,
        ]
    }

    /// Sync `editor_panel_idx` to point at the currently active `editor`.
    ///
    /// Called when the panel is opened so the cursor lands on the current
    /// selection.  Defaults to index `0` (`Editor::None`) if not found.
    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);
    }

    // ── Snackbar helpers ──────────────────────────────────────────────────────

    /// Show an info snackbar with the given message (auto-expires after 3 s).
    #[allow(dead_code)]
    pub fn notify(&mut self, msg: impl Into<String>) {
        self.snackbar = Some(Snackbar::info(msg));
    }

    /// Show an error snackbar with the given message (auto-expires after 4 s).
    pub fn notify_error(&mut self, msg: impl Into<String>) {
        self.snackbar = Some(Snackbar::error(msg));
    }

    // ── Pane accessors ────────────────────────────────────────────────────────

    pub fn active_pane(&self) -> &FileExplorer {
        match self.active {
            Pane::Left => &self.left,
            Pane::Right => &self.right,
        }
    }

    /// Return a mutable reference to the currently active pane.
    pub fn active_pane_mut(&mut self) -> &mut FileExplorer {
        match self.active {
            Pane::Left => &mut self.left,
            Pane::Right => &mut self.right,
        }
    }

    // ── Theme helpers ─────────────────────────────────────────────────────────

    /// Return a reference to the currently selected [`Theme`].
    pub fn theme(&self) -> &Theme {
        &self.themes[self.theme_idx].2
    }

    /// Return the name of the currently selected theme.
    pub fn theme_name(&self) -> &str {
        self.themes[self.theme_idx].0
    }

    /// Return the description of the currently selected theme.
    pub fn theme_desc(&self) -> &str {
        self.themes[self.theme_idx].1
    }

    /// Advance to the next theme, wrapping around at the end of the list.
    pub fn next_theme(&mut self) {
        self.theme_idx = (self.theme_idx + 1) % self.themes.len();
    }

    /// Retreat to the previous theme, wrapping around at the beginning.
    pub fn prev_theme(&mut self) {
        self.theme_idx = self
            .theme_idx
            .checked_sub(1)
            .unwrap_or(self.themes.len() - 1);
    }

    // ── File operations ───────────────────────────────────────────────────────

    /// Yank (copy or cut) into the clipboard.
    ///
    /// Marks are checked in priority order:
    /// 1. Active pane marks — the normal single-pane workflow.
    /// 2. Inactive pane marks — handles the common dual-pane workflow where
    ///    the user marks files in the source pane, tabs to the destination
    ///    pane, and then presses `y`.
    /// 3. Active pane cursor entry — fallback when nothing is marked.
    ///
    /// Marks on whichever pane was used are cleared after the yank.
    pub fn yank(&mut self, op: ClipOp) {
        let active_marks: Vec<PathBuf> = self.active_pane().marked.iter().cloned().collect();
        let inactive_marks: Vec<PathBuf> = match self.active {
            Pane::Left => self.right.marked.iter().cloned().collect(),
            Pane::Right => self.left.marked.iter().cloned().collect(),
        };

        // Determine which set of paths to use and which pane to clear marks from.
        enum Source {
            ActiveMarks,
            InactiveMarks,
            Cursor,
        }

        let source = if !active_marks.is_empty() {
            Source::ActiveMarks
        } else if !inactive_marks.is_empty() {
            Source::InactiveMarks
        } else {
            Source::Cursor
        };

        let paths: Vec<PathBuf> = match source {
            Source::ActiveMarks => {
                let mut sorted = active_marks;
                sorted.sort();
                sorted
            }
            Source::InactiveMarks => {
                let mut sorted = inactive_marks;
                sorted.sort();
                sorted
            }
            Source::Cursor => {
                if let Some(entry) = self.active_pane().current_entry() {
                    vec![entry.path.clone()]
                } else {
                    return;
                }
            }
        };

        let count = paths.len();
        let (verb, hint) = if op == ClipOp::Copy {
            ("Copied", "paste a copy")
        } else {
            ("Cut", "move")
        };

        let label = if count == 1 {
            format!(
                "'{}'",
                paths[0].file_name().unwrap_or_default().to_string_lossy()
            )
        } else {
            format!("{count} items")
        };

        self.clipboard = Some(ClipboardItem { paths, op });

        // Clear marks from whichever pane was the source.
        match source {
            Source::ActiveMarks | Source::Cursor => self.active_pane_mut().clear_marks(),
            Source::InactiveMarks => match self.active {
                Pane::Left => self.right.clear_marks(),
                Pane::Right => self.left.clear_marks(),
            },
        }

        self.status_msg = format!("{verb} {label} — press p to {hint}");
    }

    /// Paste the clipboard item into the active pane's current directory.
    ///
    /// If the destination already exists, a [`Modal::Overwrite`] is
    /// raised instead of overwriting silently.
    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();

        // For a single-item clipboard check for same-dir cut and overwrite modal.
        if clip.paths.len() == 1 {
            let src = &clip.paths[0];
            let file_name = match src.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 && src.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: src.clone(),
                    dst,
                    is_cut: clip.op == ClipOp::Cut,
                });
                return;
            }
        }

        // Multi-item (or single with no conflict): paste all paths.
        self.do_paste_all(&clip.paths.clone(), &dst_dir, clip.op == ClipOp::Cut);
    }

    /// Perform the actual copy/move for a single src→dst pair.
    ///
    /// Used by the overwrite-confirmation modal path (single file only).
    /// For multi-file paste use [`App::do_paste_all`].
    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}");
            }
        }
    }

    /// Paste all `srcs` into `dst_dir`, performing copy or move for each.
    ///
    /// Errors are collected and reported in the status message alongside the
    /// success count.  On a fully successful cut the clipboard is cleared.
    pub fn do_paste_all(&mut self, srcs: &[PathBuf], dst_dir: &Path, is_cut: bool) {
        let mut errors: Vec<String> = Vec::new();
        let mut succeeded: usize = 0;

        for src in srcs {
            let file_name = match src.file_name() {
                Some(n) => n,
                None => {
                    errors.push(format!("skipped (no filename): {}", src.display()));
                    continue;
                }
            };
            let dst = dst_dir.join(file_name);

            // Skip same-dir cut silently.
            if is_cut && src.parent() == Some(dst_dir) {
                continue;
            }

            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)
                        };
                    }
                    succeeded += 1;
                }
                Err(e) => {
                    errors.push(format!(
                        "'{}': {e}",
                        src.file_name().unwrap_or_default().to_string_lossy()
                    ));
                }
            }
        }

        if is_cut && errors.is_empty() {
            self.clipboard = None;
        }

        self.left.reload();
        self.right.reload();

        if errors.is_empty() {
            let verb = if is_cut { "Moved" } else { "Pasted" };
            self.status_msg = format!("{verb} {succeeded} item(s).");
        } else {
            self.status_msg = format!(
                "{} {succeeded}, {} error(s): {}",
                if is_cut { "Moved" } else { "Pasted" },
                errors.len(),
                errors.join("; ")
            );
        }
    }

    /// Raise a [`Modal::Delete`] for the currently highlighted entry,
    /// or a [`Modal::MultiDelete`] when there are space-marked entries
    /// in the active pane.
    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(),
            });
        }
    }

    /// Execute a confirmed multi-deletion and reload both panes.
    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("; ")
            );
        }
    }

    /// Execute a confirmed deletion and reload both panes.
    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}");
            }
        }
    }

    // ── Event handling ────────────────────────────────────────────────────────

    /// Process a single [`KeyEvent`] and update application state.
    ///
    /// This is the core key-dispatch method. Library consumers that read
    /// their own events (e.g. via a shared event loop) should call this
    /// directly instead of [`App::handle_event`].
    ///
    /// Returns `true` when the event loop should exit (user confirmed a
    /// selection or dismissed the explorer).
    ///
    /// # Example
    ///
    /// ```no_run
    /// use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
    /// use tui_file_explorer::{App, AppOptions};
    ///
    /// let mut app = App::new(AppOptions::default());
    ///
    /// // Read the event yourself and forward only key events.
    /// if let Event::Key(key) = event::read().unwrap() {
    ///     let should_exit = app.handle_key(key).unwrap();
    /// }
    /// ```
    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> io::Result<bool> {
        // Always handle Ctrl-C.
        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
            return Ok(true);
        }

        // ── Modal intercepts all input ────────────────────────────────────────
        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);
        }

        // ── Global keys (always active) ───────────────────────────────────────
        // ── Editor panel navigation (arrows / j / k steal focus when open) ───
        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);
                }
                _ => {}
            }
        }

        // ── Theme panel navigation (arrows / j / k steal focus when open) ────
        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 {
            // Cycle theme forward
            KeyCode::Char('t') if key.modifiers.is_empty() => {
                self.next_theme();
                return Ok(false);
            }
            // Cycle theme backward
            KeyCode::Char('[') => {
                self.prev_theme();
                return Ok(false);
            }
            // Toggle theme panel — closes options/editor panels if open
            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);
            }
            // Toggle options panel — closes theme/editor panels if open
            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);
            }
            // Toggle editor panel — closes theme/options panels if open
            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);
            }
            // Toggle cd-on-exit (also available in the options panel)
            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);
            }
            // Switch pane
            KeyCode::Tab => {
                self.active = self.active.other();
                return Ok(false);
            }
            // Toggle single/two-pane
            KeyCode::Char('w') if key.modifiers.is_empty() => {
                self.single_pane = !self.single_pane;
                return Ok(false);
            }
            // Copy
            KeyCode::Char('y') if key.modifiers.is_empty() => {
                self.yank(ClipOp::Copy);
                return Ok(false);
            }
            // Cut
            KeyCode::Char('x') if key.modifiers.is_empty() => {
                self.yank(ClipOp::Cut);
                return Ok(false);
            }
            // Paste
            KeyCode::Char('p') if key.modifiers.is_empty() => {
                self.paste();
                return Ok(false);
            }
            // Delete
            KeyCode::Char('d') if key.modifiers.is_empty() => {
                self.prompt_delete();
                return Ok(false);
            }
            // Open the highlighted file in the configured editor.
            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());
                        }
                        // Silently ignore dirs — no status message per spec.
                    }
                } else {
                    // No editor configured — tell the user how to set one.
                    self.notify_error("No editor set — open Editor picker (Shift + E) to pick one");
                }
                return Ok(false);
            }
            _ => {}
        }

        // ── Delegate to active pane explorer ─────────────────────────────────
        // Clear any previous non-error status when navigating.
        let outcome = self.active_pane_mut().handle_key(key);
        match outcome {
            ExplorerOutcome::Selected(path) => {
                if path.is_dir() {
                    // A directory selection just navigates — exit normally.
                    self.selected = Some(path);
                    return Ok(true);
                }
                // File selected: need an editor to open it.
                if self.editor != Editor::None {
                    self.open_with_editor = Some(path);
                    return Ok(false);
                }
                // No editor configured — stay in the TUI and tell the user.
                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") {
                    // keep error messages visible
                } else {
                    self.status_msg.clear();
                }
            }
            ExplorerOutcome::Unhandled => {}
        }

        Ok(false)
    }

    /// Read one terminal event and update application state.
    ///
    /// Calls [`event::read`] internally. If your application already owns the
    /// event loop and reads events itself, call [`App::handle_key`] instead.
    ///
    /// Returns `true` when the event loop should exit (user confirmed a
    /// selection or dismissed the explorer).
    pub fn handle_event(&mut self) -> io::Result<bool> {
        let Event::Key(key) = event::read()? else {
            return Ok(false);
        };
        self.handle_key(key)
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    // ── Editor tests ──────────────────────────────────────────────────────────

    #[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() {
        // Helix resolves to whichever of "hx" / "helix" is on $PATH, or "hx"
        // as a fallback — just verify it returns Some non-empty string.
        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() {
        // "sh" is guaranteed to exist on every Unix system we run tests on.
        #[cfg(unix)]
        assert!(
            which_on_path("sh"),
            "which_on_path should find 'sh' on Unix"
        );
        // On non-Unix just verify the function doesn't panic.
        #[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 either candidate is on $PATH the returned name must be on $PATH too.
        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;
        // 6 steps through the fixed variants should wrap back to 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() {
        // "emacs" is now a first-class variant; use a genuinely unknown string.
        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()
        });

        // Simulate the outcome that handle_key returns on Enter/l over a file.
        // We call the outcome-handling branch directly by constructing the outcome.
        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());
        // Editor::None is the default — Enter should still exit the TUI.
        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()
        });

        // A directory path must never go to open_with_editor.
        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));
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// Build a minimal `App` rooted at `dir` with sensible defaults.
    fn make_app(dir: PathBuf) -> App {
        App::new(AppOptions {
            left_dir: dir.clone(),
            right_dir: dir,
            ..AppOptions::default()
        })
    }

    // ── Pane ─────────────────────────────────────────────────────────────────

    #[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);
    }

    // ── ClipboardItem ─────────────────────────────────────────────────────────

    #[test]
    fn clipboard_item_copy_icon_and_label() {
        let item = ClipboardItem {
            paths: vec![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 {
            paths: vec![PathBuf::from("/tmp/foo")],
            op: ClipOp::Cut,
        };
        assert_eq!(item.icon(), "\u{2702} ");
        assert_eq!(item.label(), "Cut ");
    }

    #[test]
    fn clipboard_item_count_single() {
        let item = ClipboardItem {
            paths: vec![PathBuf::from("/tmp/foo")],
            op: ClipOp::Copy,
        };
        assert_eq!(item.count(), 1);
    }

    #[test]
    fn clipboard_item_count_multi() {
        let item = ClipboardItem {
            paths: vec![PathBuf::from("/tmp/a"), PathBuf::from("/tmp/b")],
            op: ClipOp::Copy,
        };
        assert_eq!(item.count(), 2);
    }

    // ── App::new ──────────────────────────────────────────────────────────────

    #[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());
    }

    // ── Snackbar helpers ──────────────────────────────────────────────────────

    #[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};
        // Build a snackbar whose expires_at is already in the past.
        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");
        // Create a file so there is a current entry.
        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(),
        };
        // Inject the event via the normal event channel is not possible in a
        // unit test, so exercise the branch directly the same way the existing
        // "enter_on_file_with_editor_*" tests do — reproduce the handler logic.
        if app.editor == Editor::None {
            app.notify_error("No editor set — open Options (Shift + O) and press e to pick one");
        }
        let _ = key; // silence unused-variable warning

        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()
        });

        // When editor != None the handler sets open_with_editor, not a snackbar.
        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"
        );
    }

    // ── Theme helpers ─────────────────────────────────────────────────────────

    #[test]
    fn theme_name_returns_str_for_idx_zero() {
        let dir = tempdir().expect("tempdir");
        let app = make_app(dir.path().to_path_buf());
        // Index 0 is always the "default" preset.
        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());
        // Advance to a known non-default index so we're not just testing the default.
        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);
    }

    // ── single_pane / show_theme_panel toggles ────────────────────────────────

    #[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);
    }

    // ── Options panel ─────────────────────────────────────────────────────────

    #[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;
        // Simulate the O key handler logic.
        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;
        // Simulate the T key handler logic.
        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());
        // Simulate the C key handler.
        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");
    }

    // ── Pane switching ────────────────────────────────────────────────────────

    #[test]
    fn active_pane_returns_left_by_default() {
        let dir = tempdir().expect("tempdir");
        let app = make_app(dir.path().to_path_buf());
        // Both panes start at the same dir; active_pane should refer to left.
        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);
    }

    // ── yank ─────────────────────────────────────────────────────────────────

    #[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);
        assert_eq!(clip.paths.len(), 1);
    }

    #[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);
        assert_eq!(clip.paths.len(), 1);
    }

    #[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_with_marks_yanks_all_marked_files() {
        let dir = tempdir().expect("tempdir");
        fs::write(dir.path().join("a.txt"), b"a").expect("write");
        fs::write(dir.path().join("b.txt"), b"b").expect("write");
        fs::write(dir.path().join("c.txt"), b"c").expect("write");
        let mut app = make_app(dir.path().to_path_buf());
        // Mark a.txt and b.txt (cursor starts at index 0).
        app.left.toggle_mark();
        app.left.toggle_mark(); // advances cursor — mark b.txt
        app.yank(ClipOp::Copy);
        let clip = app.clipboard.expect("clipboard should be set");
        assert_eq!(clip.paths.len(), 2, "should have 2 paths in clipboard");
        assert_eq!(clip.op, ClipOp::Copy);
    }

    #[test]
    fn yank_with_marks_clears_marks_after_yank() {
        let dir = tempdir().expect("tempdir");
        fs::write(dir.path().join("a.txt"), b"a").expect("write");
        fs::write(dir.path().join("b.txt"), b"b").expect("write");
        let mut app = make_app(dir.path().to_path_buf());
        app.left.toggle_mark();
        app.yank(ClipOp::Copy);
        assert!(
            app.left.marked.is_empty(),
            "marks should be cleared after yank"
        );
    }

    #[test]
    fn yank_with_marks_status_mentions_count() {
        let dir = tempdir().expect("tempdir");
        fs::write(dir.path().join("a.txt"), b"a").expect("write");
        fs::write(dir.path().join("b.txt"), b"b").expect("write");
        let mut app = make_app(dir.path().to_path_buf());
        app.left.toggle_mark();
        app.left.toggle_mark();
        app.yank(ClipOp::Copy);
        assert!(
            app.status_msg.contains("2 items"),
            "status should mention item count, got: {}",
            app.status_msg
        );
    }

    #[test]
    fn yank_uses_inactive_pane_marks_when_active_pane_has_none() {
        // Typical dual-pane workflow: mark files in LEFT, tab to RIGHT, press y.
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
        fs::write(src_dir.path().join("b.txt"), b"b").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark both files in the LEFT pane.
        app.left.toggle_mark(); // mark a.txt
        app.left.toggle_mark(); // mark b.txt

        // Tab to RIGHT pane (no marks there).
        app.active = Pane::Right;

        // Press y — should pick up left pane's marks even though active is right.
        app.yank(ClipOp::Copy);

        let clip = app.clipboard.expect("clipboard should be set");
        assert_eq!(
            clip.paths.len(),
            2,
            "both marked files should be in clipboard"
        );
        assert_eq!(clip.op, ClipOp::Copy);
    }

    #[test]
    fn yank_inactive_pane_marks_clears_inactive_pane_marks() {
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("a.txt"), b"a").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark in LEFT, switch to RIGHT, yank.
        app.left.toggle_mark();
        app.active = Pane::Right;
        app.yank(ClipOp::Copy);

        assert!(
            app.left.marked.is_empty(),
            "marks on the inactive (source) pane should be cleared after yank"
        );
        assert!(
            app.right.marked.is_empty(),
            "right pane should have no marks"
        );
    }

    #[test]
    fn yank_inactive_pane_marks_does_not_clear_active_pane_marks() {
        // Active pane has NO marks; inactive pane has marks.
        // After yank, only the inactive pane's marks should be cleared.
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("x.txt"), b"x").expect("write");
        fs::write(dst_dir.path().join("y.txt"), b"y").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark in LEFT, switch to RIGHT (no marks in right).
        app.left.toggle_mark(); // mark x.txt
        app.active = Pane::Right;

        app.yank(ClipOp::Copy);

        // LEFT marks cleared because they were the source.
        assert!(app.left.marked.is_empty(), "left marks should be cleared");
        // RIGHT marks untouched (were already empty, and should not be affected).
        assert!(
            app.right.marked.is_empty(),
            "right marks should remain empty"
        );
    }

    #[test]
    fn yank_active_pane_marks_take_priority_over_inactive_pane_marks() {
        // Both panes have marks — active pane's marks take priority.
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("left.txt"), b"l").expect("write");
        fs::write(dst_dir.path().join("right.txt"), b"r").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark in LEFT (active).
        app.left.toggle_mark(); // mark left.txt

        // Also mark in RIGHT (inactive).
        app.right.toggle_mark(); // mark right.txt

        // Active pane is LEFT — its marks should win.
        app.yank(ClipOp::Copy);

        let clip = app.clipboard.expect("clipboard should be set");
        assert_eq!(
            clip.paths.len(),
            1,
            "only active pane's mark should be used"
        );
        assert!(
            clip.paths[0].ends_with("left.txt"),
            "should have yanked the active (left) pane's marked file"
        );
    }

    #[test]
    fn yank_inactive_marks_from_right_pane_when_active_is_left_with_no_marks() {
        // Reverse of the main scenario: marks in RIGHT, active pane is LEFT.
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(dst_dir.path().join("c.txt"), b"c").expect("write");
        fs::write(dst_dir.path().join("d.txt"), b"d").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark in RIGHT pane.
        app.right.toggle_mark(); // mark c.txt
        app.right.toggle_mark(); // mark d.txt

        // Active pane is LEFT (no marks).
        assert_eq!(app.active, Pane::Left);

        app.yank(ClipOp::Copy);

        let clip = app.clipboard.expect("clipboard should be set");
        assert_eq!(clip.paths.len(), 2, "right pane marks should be used");
        assert!(
            app.right.marked.is_empty(),
            "right marks cleared after yank"
        );
    }

    #[test]
    fn yank_falls_back_to_cursor_when_no_marks_in_either_pane() {
        let dir = tempdir().expect("tempdir");
        fs::write(dir.path().join("only.txt"), b"x").expect("write");

        let mut app = make_app(dir.path().to_path_buf());
        // No marks anywhere.
        assert!(app.left.marked.is_empty());
        assert!(app.right.marked.is_empty());

        app.yank(ClipOp::Copy);

        let clip = app.clipboard.expect("clipboard should be set");
        assert_eq!(clip.paths.len(), 1, "should fall back to cursor entry");
        assert!(clip.paths[0].ends_with("only.txt"));
    }

    #[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());
    }

    // ── paste ─────────────────────────────────────────────────────────────────

    #[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);

        // Switch active pane to right and point it at dst_dir.
        app.active = Pane::Right;
        app.right.navigate_to(dst_dir.path().to_path_buf());

        app.paste();

        assert!(dst_dir.path().join("hello.txt").exists());
        // Source file must still exist after a copy.
        assert!(src_dir.path().join("hello.txt").exists());
    }

    #[test]
    fn paste_multi_copy_creates_all_files_in_destination() {
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
        fs::write(src_dir.path().join("b.txt"), b"b").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        // Mark both files and yank.
        app.left.toggle_mark();
        app.left.toggle_mark();
        app.yank(ClipOp::Copy);

        app.active = Pane::Right;
        app.paste();

        assert!(
            dst_dir.path().join("a.txt").exists(),
            "a.txt should be copied"
        );
        assert!(
            dst_dir.path().join("b.txt").exists(),
            "b.txt should be copied"
        );
        // Sources must survive a copy.
        assert!(src_dir.path().join("a.txt").exists());
        assert!(src_dir.path().join("b.txt").exists());
    }

    #[test]
    fn paste_multi_cut_moves_all_files_and_clears_clipboard() {
        let src_dir = tempdir().expect("src tempdir");
        let dst_dir = tempdir().expect("dst tempdir");
        fs::write(src_dir.path().join("a.txt"), b"a").expect("write");
        fs::write(src_dir.path().join("b.txt"), b"b").expect("write");

        let mut app = App::new(AppOptions {
            left_dir: src_dir.path().to_path_buf(),
            right_dir: dst_dir.path().to_path_buf(),
            ..AppOptions::default()
        });

        app.left.toggle_mark();
        app.left.toggle_mark();
        app.yank(ClipOp::Cut);

        app.active = Pane::Right;
        app.paste();

        assert!(
            dst_dir.path().join("a.txt").exists(),
            "a.txt should be moved"
        );
        assert!(
            dst_dir.path().join("b.txt").exists(),
            "b.txt should be moved"
        );
        assert!(
            !src_dir.path().join("a.txt").exists(),
            "a.txt should be gone from src"
        );
        assert!(
            !src_dir.path().join("b.txt").exists(),
            "b.txt should be gone from src"
        );
        assert!(app.clipboard.is_none(), "clipboard cleared after cut-paste");
    }

    #[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);
        // Active pane is still the same dir.
        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"
        );
    }

    // ── do_paste ──────────────────────────────────────────────────────────────

    #[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());
        // Put something in clipboard so it can be cleared.
        app.clipboard = Some(ClipboardItem {
            paths: vec![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");
        // src does not exist — copy will fail.
        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"));
    }

    // ── prompt_delete / confirm_delete ────────────────────────────────────────

    #[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"));
    }

    // ── status_msg clearing behaviour ────────────────────────────────────────

    #[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()
        });
        // Seed an old status message to prove it gets replaced.
        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"); // does not exist
        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
        );
    }

    // ── paste edge cases ──────────────────────────────────────────────────────

    #[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());
        // A path with no filename component (e.g. "/" on Unix).
        app.clipboard = Some(ClipboardItem {
            paths: vec![PathBuf::from("/")],
            op: ClipOp::Copy,
        });
        app.paste();
        assert_eq!(
            app.status_msg,
            "Cannot paste: clipboard path has no filename."
        );
    }

    // ── both panes reload after operations ────────────────────────────────────

    #[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());
        // Both panes start in the same directory. After delete the file must
        // not appear in either entry list.
        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");
    }

    // ── multi-delete: toggle_mark / prompt_delete / confirm_delete_many ───────

    #[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());

        // cursor is on the first file; Space should mark it.
        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(); // mark
        app.left.cursor = 0; // reset cursor (toggle_mark moved it down)
        app.left.toggle_mark(); // unmark same entry
        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());

        // Mark both files.
        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());

        // No marks — should fall back to the single-item modal.
        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"); // never created

        let mut app = make_app(dir.path().to_path_buf());
        app.confirm_delete_many(&[real, ghost]);

        // "1" deleted + error mention expected in status.
        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());
        // Simulate cancellation: set the modal manually then take it away.
        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());
        // Navigate into subdir, mark the file, then ascend.
        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());
        // navigate_to resets cursor/scroll but does NOT call ascend, so we
        // trigger ascend explicitly via the key path.
        // Instead directly verify the marks survive navigate_to (they should,
        // since only ascend/descend clear them) then clear manually.
        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());
        // Mark the subdirectory entry in the left pane.
        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"
        );

        // Descend into sub — marks should be cleared.
        app.left.navigate_to(sub);
        // navigate_to itself doesn't clear marks; only confirm() (Enter/l/→) does.
        // Verify via clear_marks as the underlying primitive.
        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());

        // Mark all files.
        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");
        }
    }

    // ── Tab key switches active pane ──────────────────────────────────────────

    #[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);
        // Simulate Tab via the active field directly (handle_event reads stdin).
        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);
    }

    // ── App::new — themes list ────────────────────────────────────────────────

    #[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);
    }

    // ── next_theme / prev_theme index bounds ──────────────────────────────────

    #[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
            );
        }
    }

    // ── do_paste status on success ────────────────────────────────────────────

    #[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
        );
    }

    // ── inactive pane accessor ────────────────────────────────────────────────

    #[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);
        // When left is active, accessing the "other" pane via active.other()
        // should give Right — validate via the Pane::other helper.
        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);
    }

    // ── active_pane_mut ───────────────────────────────────────────────────────

    #[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);
    }

    // ── single_pane toggle ────────────────────────────────────────────────────

    #[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);
    }

    // ── AppOptions default ────────────────────────────────────────────────────

    #[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);
    }
}
