//! File-explorer as a real read-only buffer window (#55) — Neovim neo-tree/oil style.
//!
//! The tree **model** (`ExplorerTree`: root, expanded set, flat node list) is
//! kept from the previous implementation. `render_text` produces the buffer
//! text and a line→node map. Everything else (custom render, key handler,
//! focus flag, scroll, selection, mouse zone) is gone — the engine provides
//! those for free because the explorer is now a real left window in the layout
//! tree.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

// ── Tree model ─────────────────────────────────────────────────────────────────

/// One visible row in the flattened tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExplorerNode {
    pub path: PathBuf,
    /// Nesting depth: root is depth 0, its children depth 1, …
    pub depth: usize,
    pub is_dir: bool,
    /// True when this node is the last child of its parent (`└╴` vs `├╴`).
    pub is_last: bool,
    /// For each ancestor level above this node (excluding the root): whether
    /// that ancestor has a following sibling (draw a `│` in that column).
    /// Length `depth - 1` for `depth >= 1`; empty for the root.
    pub branches: Vec<bool>,
    /// Git status for this path (from the workdir status map). `None` when the
    /// path is clean or the explorer root is outside a git repo.
    pub git: Option<hjkl_app::git::ExplorerGit>,
}

/// Pure tree model. Owned by `ExplorerPane` on `App`.
#[derive(Debug, Clone)]
pub struct ExplorerTree {
    /// Root of the tree (cwd when the explorer was opened).
    pub(crate) root: PathBuf,
    /// Directories whose fold is OPEN (absolute paths).
    /// The full tree is always in `nodes`; this set drives fold closed/open state.
    /// The root is always open by default.
    expanded: HashSet<PathBuf>,
    /// Flattened depth-first list of ALL nodes (entire tree, regardless of
    /// expansion). Collapsed dirs are present but their subtrees are hidden via
    /// buffer folds. Indexed 1:1 with buffer lines after `render_text`.
    pub(crate) nodes: Vec<ExplorerNode>,
    /// When `false`, entries whose name starts with `.` are hidden. Defaults to
    /// `true` (dotfiles shown), which is [`hjkl_fs::project`]'s policy and so
    /// what the file picker lists and both grep paths search; `H` narrows the
    /// tree below that for the session. The `.git` dir is skipped regardless.
    pub(crate) show_hidden: bool,
    /// When `true` (default), the tree lists exactly what [`hjkl_fs::project`]
    /// calls the project — ignore-file rules honoured, ignored directories
    /// pruned rather than walked. `I` turns that off to reveal ignored entries,
    /// which is the one explorer view WIDER than the shared policy: those paths
    /// are deliberately not listed by the pickers or searched by `:grep`.
    pub(crate) respect_gitignore: bool,
    /// Last-known on-disk git status map (keyed by the same absolute paths as
    /// [`hjkl_app::git::explorer_status_map`] produces). Populated on each
    /// full rebuild; used by [`Self::retag_git`] for cheap live overlay.
    pub(crate) git_base: HashMap<PathBuf, hjkl_app::git::ExplorerGit>,
    /// `true` when `root` is inside a git repository (cached from the last
    /// rebuild). Gates `retag_git` so no git syscalls occur per keystroke
    /// outside a repo.
    pub(crate) repo_present: bool,
}

impl ExplorerTree {
    /// Create a fresh tree rooted at `root`. The root starts expanded so its
    /// children are visible immediately. Dotfiles are shown by default;
    /// git-ignored entries are hidden by default.
    pub(crate) fn new(root: PathBuf) -> Self {
        let mut expanded = HashSet::new();
        expanded.insert(root.clone());
        let mut tree = Self {
            root,
            expanded,
            nodes: Vec::new(),
            show_hidden: true,
            respect_gitignore: true,
            git_base: HashMap::new(),
            repo_present: false,
        };
        tree.rebuild();
        tree
    }

    /// Read one directory's children, sorted directories-first then by
    /// case-insensitive name.
    ///
    /// The default listing is [`hjkl_fs::project::list_dir`] — the same
    /// gitignore-and-dotfiles policy the file picker walks and both grep paths
    /// search, so a file visible here is a file those can find. The two
    /// explorer toggles are overrides ON that policy rather than a second
    /// implementation of it: `H` (`show_hidden = false`) additionally drops
    /// dotfiles, and `I` (`respect_gitignore = false`) additionally restores
    /// the ignored entries the shared walk pruned. `.git` stays out either way.
    fn read_children(&self, dir: &Path) -> Vec<(PathBuf, bool)> {
        let mut entries: Vec<(PathBuf, bool)> = if self.respect_gitignore {
            hjkl_fs::project::list_dir(dir)
        } else {
            // `I` — show ignored entries too. That is a wider set than any
            // configuration of the shared walk yields, so it is a plain
            // directory read with only the `.git` exclusion kept.
            match std::fs::read_dir(dir) {
                Ok(rd) => rd
                    .filter_map(|e| e.ok())
                    .filter(|e| e.file_name() != hjkl_fs::project::GIT_DIR)
                    .map(|e| {
                        let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
                        (e.path(), is_dir)
                    })
                    .collect(),
                Err(_) => Vec::new(),
            }
        };
        if !self.show_hidden {
            entries.retain(|(p, _)| {
                !p.file_name()
                    .is_some_and(|n| n.to_string_lossy().starts_with('.'))
            });
        }
        entries.sort_by(|(a, a_dir), (b, b_dir)| {
            b_dir.cmp(a_dir).then_with(|| {
                let an = a.file_name().map(|n| n.to_string_lossy().to_lowercase());
                let bn = b.file_name().map(|n| n.to_string_lossy().to_lowercase());
                an.cmp(&bn)
            })
        });
        entries
    }

    /// Recursively append `dir`'s expanded children to `out`.
    ///
    /// `status` carries the git workdir status map built once per rebuild.
    /// Each file/dir node gets its `git` field set from the map. Deleted files
    /// (present in `status` with `ExplorerGit::Deleted` but absent from the
    /// filesystem) are injected at the end of the directory's child list and
    /// re-sorted so they appear in tree order.
    fn push_children(
        &self,
        dir: &Path,
        depth: usize,
        prefix: &[bool],
        out: &mut Vec<ExplorerNode>,
        status: &HashMap<PathBuf, hjkl_app::git::ExplorerGit>,
    ) {
        let mut children = self.read_children(dir);

        // Inject deleted files: paths in the status map whose parent is `dir`
        // and whose status is Deleted, that are not already present on disk.
        // Collect disk paths into owned set first to avoid borrow conflicts.
        let disk_paths: HashSet<PathBuf> = children.iter().map(|(p, _)| p.clone()).collect();
        for (abs_path, &git_status) in status.iter() {
            if git_status != hjkl_app::git::ExplorerGit::Deleted {
                continue;
            }
            if abs_path.parent() != Some(dir) {
                continue;
            }
            if disk_paths.contains(abs_path) {
                continue;
            }
            // Not on disk — inject as a file entry.
            children.push((abs_path.clone(), false));
        }

        // Re-sort: dirs first, then case-insensitive name (same order as read_children).
        children.sort_by(|(a, a_dir), (b, b_dir)| {
            b_dir.cmp(a_dir).then_with(|| {
                let an = a.file_name().map(|n| n.to_string_lossy().to_lowercase());
                let bn = b.file_name().map(|n| n.to_string_lossy().to_lowercase());
                an.cmp(&bn)
            })
        });

        let n = children.len();
        for (i, (path, is_dir)) in children.into_iter().enumerate() {
            let is_last = i + 1 == n;
            let git = status.get(&path).copied();
            out.push(ExplorerNode {
                path: path.clone(),
                depth,
                is_dir,
                is_last,
                branches: prefix.to_vec(),
                git,
            });
            // Lazy walk: descend ONLY into directories the user has expanded.
            // A collapsed dir contributes a single node and its subtree is left
            // unread — so opening the explorer in a huge tree (a home dir) only
            // reads the visible levels, never the whole disk. Re-expanding a dir
            // re-reads its one level (cheap); search reaches the rest via the
            // fuzzy file finder, not by walking everything up front.
            if is_dir && self.expanded.contains(&path) {
                let mut child_prefix = prefix.to_vec();
                child_prefix.push(!is_last);
                self.push_children(&path, depth + 1, &child_prefix, out, status);
            }
        }
    }

    /// Rebuild the flattened node list from the current expansion state.
    pub(crate) fn rebuild(&mut self) {
        // Build the git status map once for the whole walk. Empty when not in a
        // repo — no git2 calls are made inside push_children in that case.
        let status = hjkl_app::git::explorer_status_map(&self.root);
        self.repo_present = git2::Repository::discover(&self.root).is_ok();
        self.git_base.clone_from(&status);

        let mut out = Vec::new();
        let root = self.root.clone();
        out.push(ExplorerNode {
            path: root.clone(),
            depth: 0,
            is_dir: true,
            is_last: true,
            branches: Vec::new(),
            git: None, // root dir rollup applied below
        });
        // Always walk the full tree — every node (including children of collapsed
        // dirs) is added to `nodes`. Fold state is managed via `compute_folds`.
        self.push_children(&root, 1, &[], &mut out, &status);
        roll_up_dir_status(&mut out);
        self.nodes = out;
    }

    /// The explorer no longer uses buffer folds: with the lazy walk a collapsed
    /// directory's children are simply absent from `nodes`, so the buffer text
    /// already *is* the visible tree (no hidden lines to fold away). Expand /
    /// collapse adds or removes nodes via `set_expanded` + `rebuild`, and the
    /// open/closed icon is driven directly by the `expanded` set in the render
    /// overlay. Kept returning an empty list so the existing `set_folds`
    /// call-sites (`open_explorer`, `explorer_rebuild_buffer`) stay unchanged.
    pub(crate) fn compute_folds(&self) -> Vec<hjkl_buffer::Fold> {
        Vec::new()
    }

    /// Re-tag every node's `git` field from `status` without a filesystem walk
    /// or tree-structure change, then recompute directory rollups.
    ///
    /// Used for live refresh on buffer edits: the caller merges the on-disk
    /// `git_base` with any in-memory overlay (open dirty buffers) and passes
    /// the result here. Cheap — O(nodes) with no git2 calls.
    pub(crate) fn retag_git(&mut self, status: &HashMap<PathBuf, hjkl_app::git::ExplorerGit>) {
        for node in &mut self.nodes {
            node.git = status.get(&node.path).copied();
        }
        roll_up_dir_status(&mut self.nodes);
    }

    /// Toggle the expansion of the directory at `path` in the `expanded` set.
    /// Test-only helper — runtime toggling flips the buffer fold directly via
    /// `toggle_fold_at` and syncs `expanded` with `set_expanded`.
    #[cfg(test)]
    pub(crate) fn toggle(&mut self, path: &Path) -> bool {
        if self.expanded.contains(path) {
            self.expanded.remove(path);
        } else {
            self.expanded.insert(path.to_path_buf());
        }
        true
    }

    /// `true` when `path` is an expanded directory. Drives the open/closed
    /// folder icon in the render overlay (the lazy explorer has no buffer folds,
    /// so the icon reads the `expanded` set directly).
    pub(crate) fn is_expanded(&self, path: &Path) -> bool {
        self.expanded.contains(path)
    }

    /// Set a directory's expanded (fold-open) state explicitly. Used to keep
    /// `expanded` in sync with the buffer's actual fold state after a toggle.
    pub(crate) fn set_expanded(&mut self, path: &Path, expanded: bool) {
        if expanded {
            self.expanded.insert(path.to_path_buf());
        } else {
            self.expanded.remove(path);
        }
    }

    /// Flip `show_hidden` and rebuild.
    pub(crate) fn toggle_hidden(&mut self) {
        self.show_hidden = !self.show_hidden;
        self.rebuild();
    }

    /// Flip `respect_gitignore` (show/hide git-ignored entries) and rebuild.
    pub(crate) fn toggle_gitignore(&mut self) {
        self.respect_gitignore = !self.respect_gitignore;
        self.rebuild();
    }

    /// Re-root the tree at `new_root`, preserving the existing `expanded` set.
    /// The new root is automatically added to `expanded`.
    pub(crate) fn set_root(&mut self, new_root: PathBuf) {
        self.root.clone_from(&new_root);
        self.expanded.insert(new_root);
        self.rebuild();
    }

    /// Open all ancestor dirs of `path` (add them to `expanded`) and return the
    /// row of `path` in `self.nodes`, or `None` if `path` is not under the root.
    ///
    /// With the full-tree model the node is always present in `nodes` (the full
    /// walk includes it regardless of expansion). This method only needs to open
    /// the ancestor folds via `expanded` — no filesystem re-walk needed. However
    /// when called from `open_explorer` (tree was just built) or externally a
    /// `rebuild` is sometimes needed; callers decide. Here we only update
    /// `expanded` and find the row in the already-built node list.
    ///
    /// Robust to path-form differences (relative vs absolute, symlinked cwd).
    pub(crate) fn reveal(&mut self, path: &Path) -> Option<usize> {
        // Determine `path` relative to root, tolerating canonicalization diffs.
        let rel = path
            .strip_prefix(&self.root)
            .ok()
            .map(|p| p.to_path_buf())
            .or_else(|| {
                let rc = std::fs::canonicalize(&self.root).ok()?;
                let pc = std::fs::canonicalize(path).ok()?;
                pc.strip_prefix(&rc).ok().map(|p| p.to_path_buf())
            })?;

        // Reconstruct the node path from the root + relative components.
        let mut target = self.root.clone();
        for comp in rel.components() {
            target = target.join(comp);
        }

        // Open every ancestor directory (add to `expanded` so folds open).
        self.expanded.insert(self.root.clone());
        let mut anc = target.parent();
        while let Some(p) = anc {
            self.expanded.insert(p.to_path_buf());
            if p == self.root {
                break;
            }
            anc = p.parent();
        }

        // With the full-tree model the node is always present. If it's not found
        // (e.g. path doesn't exist yet), fall back to a rebuild so new nodes are
        // picked up from disk.
        if let Some(row) = self.nodes.iter().position(|n| n.path == target) {
            return Some(row);
        }
        // Node not found — rebuild from disk (handles the initial open case where
        // the tree may not have been walked yet for a freshly-created path).
        self.rebuild();
        self.nodes.iter().position(|n| n.path == target)
    }

    /// Build the buffer text and line→node map for the current tree state.
    ///
    /// Each line in the returned `String` corresponds to `nodes[i]`, so
    /// `cursor_row` in the editor maps directly to `nodes[cursor_row]`.
    ///
    /// The buffer contains indentation spaces + the bare name + a hidden id
    /// tail (`<US><idx>`) for all non-root lines.  The root line (index 0) has
    /// no id tail.  All glyphs are painted as a render overlay in `render.rs`.
    ///
    /// Column layout (identical to what was emitted before; the overlay paints
    /// the leading cells):
    ///   depth 0  : `"  " + name`               (no id tail)
    ///   depth ≥ 1: `" ".repeat(depth*2+2) + name[/] + US + idx`
    pub(crate) fn render_text(&self) -> String {
        use super::explorer_reconcile::ID_SEP;
        let mut out = String::new();
        for (i, node) in self.nodes.iter().enumerate() {
            if i > 0 {
                out.push('\n');
            }
            let name_col = node.depth * 2 + 2;
            let name = if node.depth == 0 {
                node.path.file_name().map_or_else(
                    || node.path.to_string_lossy().into_owned(),
                    |n| n.to_string_lossy().into_owned(),
                )
            } else {
                let base = node
                    .path
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                if node.is_dir {
                    format!("{base}/")
                } else {
                    base
                }
            };
            out.push_str(&" ".repeat(name_col));
            out.push_str(&name);
            // Non-root lines carry the stable id so reconcile can key on it.
            if node.depth > 0 {
                out.push(ID_SEP);
                out.push_str(&i.to_string());
            }
        }
        out
    }
}

// ── App-level explorer state ───────────────────────────────────────────────────

/// Tracks the open explorer window + slot so `toggle_explorer` can close it.
#[derive(Debug, Clone)]
pub struct ExplorerPane {
    /// WindowId of the explorer window in the active tab's layout.
    pub win_id: super::window::WindowId,
    /// The tree model.
    pub tree: ExplorerTree,
    /// The `dirty_gen` of the explorer buffer at the last successful reconcile.
    /// Prevents re-running the diff on an unchanged buffer.
    pub last_reconcile_gen: u64,
    /// Registry of files moved to trash during this pane's lifetime.
    /// Each entry is `(original_file_name, trash_destination_path)`.
    /// Used to restore ("un-trash") when a `CreateFile` op targets a name
    /// matching a trashed entry — making `dd` + `p` a lossless move.
    pub trashed: Vec<(String, std::path::PathBuf)>,
    /// The last-reconciled buffer state: `(id, absolute_path, is_dir)` per
    /// line, index 0 = root (id=0). This is the baseline for `reconcile()`.
    /// Kept in sync with every reconcile cycle and every
    /// `explorer_rebuild_buffer` call so the diff always starts from the
    /// correct "last-known-good" state. The `id` is the node's index in
    /// `tree.nodes` at the time the buffer was last rendered.
    pub baseline: Vec<(u64, PathBuf, bool)>,
    /// Undo stack: each entry is one reconcile transaction
    /// (`Vec<AppliedOp>`) that can be reverted by [`App::explorer_undo`].
    pub undo_stack: Vec<Vec<super::explorer_reconcile::AppliedOp>>,
    /// Redo stack: each entry is the redo journal produced by
    /// [`super::explorer_reconcile::revert_ops`], ready to be re-applied by
    /// [`App::explorer_redo`].
    pub redo_stack: Vec<Vec<super::explorer_reconcile::AppliedOp>>,
    /// Where `dd` moves entries. Always [`hjkl_app::trash::TrashRoot::Xdg`] in
    /// production — the injectable variant exists so a test can point the trash
    /// at its own `TempDir` instead of overriding `XDG_CACHE_HOME`.
    ///
    /// That override is what made the `dd`/`u`/`<C-r>` tests flaky: the
    /// environment is process-global, so while one of them held the override,
    /// any *other* test thread constructing an `App` resolved
    /// `$XDG_CACHE_HOME/hjkl/swap` — creating an `hjkl/` directory inside the
    /// overriding test's own explorer root. A stray directory sorts above the
    /// file the test meant to delete, so `j` then `dd` targeted `hjkl/` and the
    /// victim survived ("dd must trash"). An explicit root has no such reach.
    pub trash_root: hjkl_app::trash::TrashRoot,
}

// ── Render-derivation cache ────────────────────────────────────────────────────

/// Per-frame handle to the explorer's text-derived render data (#312 / P7).
///
/// Every field is an `Arc` clone of the cached build, so handing one out per
/// frame is O(1). Rebuilt only when the explorer buffer's `dirty_gen` (or the
/// tree root) changes — see [`ExplorerRenderCache`].
#[derive(Debug, Clone)]
pub struct ExplorerRenderData {
    /// Whole-buffer text (the buffer's own `dirty_gen`-cached `Arc<String>`).
    pub text: std::sync::Arc<String>,
    /// Byte range `[start, end)` of every buffer row, with `split('\n')`
    /// semantics — index N is buffer row N, including the trailing empty row
    /// a document-final newline produces.
    pub line_spans: std::sync::Arc<Vec<(usize, usize)>>,
    /// One conceal per row carrying an `<US><id>` tail, hiding the tail.
    pub conceals: std::sync::Arc<Vec<hjkl_buffer_tui::Conceal>>,
    /// Tree layout parsed from the live buffer text, 1:1 with buffer rows.
    /// Empty when the explorer pane is closed (no root to resolve against).
    pub overlay_nodes: std::sync::Arc<Vec<Option<ExplorerNode>>>,
}

impl ExplorerRenderData {
    /// The text of buffer row `row`, with `str::lines()` semantics (a single
    /// trailing `\r` stripped). `None` past the last row.
    ///
    /// Replaces the old `text.lines().nth(row)` walk, which was O(row) per
    /// call and therefore O(viewport × N) per frame.
    pub fn line(&self, row: usize) -> Option<&str> {
        let &(start, end) = self.line_spans.get(row)?;
        let line = &self.text[start..end];
        Some(line.strip_suffix('\r').unwrap_or(line))
    }
}

/// `dirty_gen`-keyed cache for the explorer's text-derived render data.
///
/// The explorer pane redoes the same whole-buffer passes on every frame while
/// visible (a common steady state): a rope→String copy, a conceal per line, a
/// full re-parse of the tree text, and an O(row) line walk per visible row.
/// None of that depends on anything but the buffer text (plus the tree root),
/// and the text only changes through `set_content` / an edit — both of which
/// bump `dirty_gen`. So key the whole bundle on `(buffer_id, dirty_gen, root)`
/// and rebuild only then. The `buffer_id` is in the key because closing and
/// reopening the explorer builds a FRESH slot whose `dirty_gen` restarts from
/// zero — without it a reopen at the same root could hit data built from the
/// previous pane's text.
///
/// Deliberately NOT cached here (they change without a text edit, so they stay
/// per-frame in `render.rs`): git status colors, the `expanded` set that drives
/// the dir icon, folds, cursor/selection.
#[derive(Debug, Default)]
pub struct ExplorerRenderCache {
    /// `(buffer id, dirty_gen, tree root)` the cached data was built from.
    key: Option<(hjkl_lsp::BufferId, u64, Option<PathBuf>)>,
    data: Option<ExplorerRenderData>,
}

impl ExplorerRenderCache {
    /// Return the render data for `text` (the buffer's `dirty_gen`-cached
    /// `Arc<String>`), rebuilding it only when the explorer buffer, its
    /// `dirty_gen`, or the tree root differs from the cached key.
    pub(crate) fn get(
        &mut self,
        buffer_id: hjkl_lsp::BufferId,
        dirty_gen: u64,
        root: Option<&Path>,
        text: std::sync::Arc<String>,
    ) -> ExplorerRenderData {
        if let (Some((cached_id, cached_gen, cached_root)), Some(data)) = (&self.key, &self.data)
            && *cached_id == buffer_id
            && *cached_gen == dirty_gen
            && cached_root.as_deref() == root
        {
            return data.clone();
        }

        // ── Line index + conceals, one pass over the text ────────────────────
        //
        // Conceals hide the `<US><id>` tail on each non-root line: the range
        // `[US byte .. end of line]` with an empty replacement. Byte offsets are
        // line-relative, matching the old `text.lines().enumerate()` build (a
        // trailing empty row carries no `US`, so it produces no conceal either
        // way).
        use super::explorer_reconcile::ID_SEP;
        let mut line_spans: Vec<(usize, usize)> = Vec::new();
        let mut conceals: Vec<hjkl_buffer_tui::Conceal> = Vec::new();
        let mut pos = 0usize;
        for raw in text.split('\n') {
            let start = pos;
            let end = pos + raw.len();
            pos = end + 1; // skip the '\n' separator
            let row = line_spans.len();
            line_spans.push((start, end));
            let line = raw.strip_suffix('\r').unwrap_or(raw);
            if let Some(us_byte) = line.find(ID_SEP) {
                conceals.push(hjkl_buffer_tui::Conceal {
                    row,
                    start_byte: us_byte,
                    end_byte: line.len(),
                    replacement: String::new(),
                });
            }
        }

        let overlay_nodes = match root {
            Some(r) => overlay_nodes_from_buffer(&text, r),
            None => Vec::new(),
        };

        let data = ExplorerRenderData {
            text,
            line_spans: std::sync::Arc::new(line_spans),
            conceals: std::sync::Arc::new(conceals),
            overlay_nodes: std::sync::Arc::new(overlay_nodes),
        };
        self.key = Some((buffer_id, dirty_gen, root.map(Path::to_path_buf)));
        self.data = Some(data.clone());
        data
    }
}

// ── nodes_from_buffer ──────────────────────────────────────────────────────────

/// Derive the render-tree (`Vec<ExplorerNode>`) from the current buffer text.
/// Used only in tests (round-trip assertions); production code now uses
/// `ExplorerTree::rebuild` for all render-tree updates.
///
/// Parses the buffer using the same indent→depth rules as
/// `explorer_reconcile::parse_buffer` (which `reconcile()` calls internally),
/// then fills in `is_last` and `branches` by scanning forward in the depth
/// sequence, mirroring the logic used by `ExplorerTree::push_children` so the
/// guide overlay produced by `render.rs` stays aligned.
///
/// The root line (line 0) becomes a depth-0 node for `path = root`.
/// Subsequent lines: `depth = ((indent - 2) / 2).max(1)`.
///
/// `is_last[i]` — scanning forward from `i`, if the first node whose
/// `depth < nodes[i].depth` is reached before finding a node with
/// `depth == nodes[i].depth`, then `is_last = true`.
///
/// `branches[i]` (length `depth - 1` for `depth >= 1`; empty for depth 0):
/// for each ancestor level `a` in `1..depth`, the entry is `!is_last` of the
/// most-recent earlier node at depth `a`. I.e. draw a vertical bar at an
/// ancestor column iff that ancestor still has a following sibling.
#[cfg_attr(not(test), allow(dead_code))]
pub fn nodes_from_buffer(text: &str, root: &Path) -> Vec<ExplorerNode> {
    overlay_nodes_from_buffer(text, root)
        .into_iter()
        .flatten()
        .collect()
}

/// Like [`nodes_from_buffer`], but returns ONE entry per buffer line — `None`
/// for blank / nameless lines — so the result indexes **1:1 with buffer rows**.
///
/// This is what the `render.rs` glyph/color overlay walks: deriving the tree
/// layout from the LIVE buffer text (rather than the last-reconciled
/// `ExplorerTree::nodes`) keeps icons, guides, and git colors aligned **while
/// the buffer is being edited** — before the Normal-mode reconcile rebuilds the
/// tree. A mid-edit `o`/`O` inserts a blank indented line; mapping the overlay
/// off the stale node list shifted every glyph below the edit down a row. Here
/// that blank line is a `None` slot, so everything stays put.
///
/// `git` is left `None` on every node — the overlay resolves git status by
/// path against the reconciled tree's status map (so a row's color follows its
/// path, not its row index). See `render.rs`.
pub fn overlay_nodes_from_buffer(text: &str, root: &Path) -> Vec<Option<ExplorerNode>> {
    // ── Pass 1: parse path / depth / is_dir, one slot per buffer line ────────
    struct RawNode {
        path: PathBuf,
        depth: usize,
        is_dir: bool,
    }

    // `split('\n')` (not `lines()`) so a trailing newline yields a final empty
    // slot, matching the buffer's row count exactly.
    let mut raw: Vec<Option<RawNode>> = Vec::new();
    let mut dir_stack: Vec<(usize, PathBuf)> = Vec::new(); // (depth, abs_path)

    for (line_idx, line) in text.split('\n').enumerate() {
        use super::explorer_reconcile::ID_SEP;
        if line_idx == 0 {
            // Root line — always depth 0, no id tail.
            raw.push(Some(RawNode {
                path: root.to_path_buf(),
                depth: 0,
                is_dir: true,
            }));
            dir_stack.push((0, root.to_path_buf()));
            continue;
        }
        // Strip the id tail (everything from the first ID_SEP onward) before
        // parsing indent/name. This leaves the name side verbatim.
        let left = if let Some(sep_pos) = line.find(ID_SEP) {
            &line[..sep_pos]
        } else {
            line
        };
        if left.trim().is_empty() {
            raw.push(None);
            continue;
        }
        let indent = left.len() - left.trim_start_matches(' ').len();
        let depth = ((indent.saturating_sub(2)) / 2).max(1);
        // Name is verbatim between indent and the stripped id tail.
        // Do NOT trim_end — whitespace in names must be preserved.
        let name_part = &left[indent..];
        let is_dir = name_part.ends_with('/');
        let name = if is_dir {
            &name_part[..name_part.len() - 1]
        } else {
            name_part
        };
        if name.is_empty() {
            raw.push(None);
            continue;
        }
        // Pop dir_stack entries at depth >= current.
        while dir_stack.last().is_some_and(|(d, _)| *d >= depth) {
            dir_stack.pop();
        }
        let parent = dir_stack
            .last()
            .filter(|(d, _)| *d == depth - 1)
            .map_or(root, |(_, p)| p.as_path());
        let path = parent.join(name);
        if is_dir {
            dir_stack.push((depth, path.clone()));
        }
        raw.push(Some(RawNode {
            path,
            depth,
            is_dir,
        }));
    }

    let n = raw.len();
    if n == 0 {
        return Vec::new();
    }

    // ── Pass 2: compute is_last (per real node; blanks are transparent) ──────
    let mut is_last_flags: Vec<bool> = vec![true; n];
    for i in 0..n {
        let Some(ref cur) = raw[i] else { continue };
        let d = cur.depth;
        for slot in raw.iter().skip(i + 1) {
            let Some(node) = slot else { continue };
            if node.depth < d {
                break; // left the parent's scope
            }
            if node.depth == d {
                is_last_flags[i] = false;
                break;
            }
        }
    }

    // ── Pass 3: compute branches, scattering into a per-line Option vec ──────
    // For each node at depth d (d >= 1), branches[a-1] for a in 1..d is
    // `!is_last` of the most-recent ancestor at depth a.
    let mut ancestor_is_last: Vec<Option<bool>> = vec![None; n + 2];

    let mut out: Vec<Option<ExplorerNode>> = Vec::with_capacity(n);
    for i in 0..n {
        let Some(ref cur) = raw[i] else {
            out.push(None);
            continue;
        };
        let d = cur.depth;
        // Update the ancestor table for this depth.
        if d < ancestor_is_last.len() {
            ancestor_is_last[d] = Some(is_last_flags[i]);
        }

        let branches: Vec<bool> = if d == 0 {
            Vec::new()
        } else {
            (1..d)
                .map(|a| {
                    // draw a bar if the ancestor at depth `a` is NOT last
                    ancestor_is_last
                        .get(a)
                        .and_then(|v| *v)
                        .is_some_and(|last| !last)
                })
                .collect()
        };

        out.push(Some(ExplorerNode {
            path: cur.path.clone(),
            depth: d,
            is_dir: cur.is_dir,
            is_last: is_last_flags[i],
            branches,
            git: None, // resolved by path in the render overlay
        }));
    }

    out
}

// ── Git status rollup ─────────────────────────────────────────────────────────

/// Precedence order for directory rollup: higher index = higher priority.
pub fn git_status_priority(s: hjkl_app::git::ExplorerGit) -> u8 {
    use hjkl_app::git::ExplorerGit::*;
    match s {
        Deleted => 1,
        Untracked => 2,
        Staged => 3,
        Modified => 4,
    }
}

/// Roll up git status from descendant files onto ancestor directory nodes.
///
/// After `push_children` has populated the flat DFS-ordered list, this pass
/// sets each dir node's `git` to the highest-priority status among its
/// descendants. Relies on DFS order: every node at index `i+1..` whose depth
/// is greater than `nodes[i].depth` belongs to the subtree of `nodes[i]`.
fn roll_up_dir_status(nodes: &mut [ExplorerNode]) {
    for i in 0..nodes.len() {
        if !nodes[i].is_dir {
            continue;
        }
        let dir_depth = nodes[i].depth;
        let mut best: Option<hjkl_app::git::ExplorerGit> = nodes[i].git;
        // Split the slice so we can read `tail[j]` while holding a mutable ref
        // to `head[i]` later. `tail` starts at i+1.
        let tail = &nodes[i + 1..];
        for desc in tail {
            if desc.depth <= dir_depth {
                break; // left the subtree
            }
            if let Some(s) = desc.git {
                best = Some(match best {
                    None => s,
                    Some(b) => {
                        if git_status_priority(s) > git_status_priority(b) {
                            s
                        } else {
                            b
                        }
                    }
                });
            }
        }
        nodes[i].git = best;
    }
}

// ── App methods ────────────────────────────────────────────────────────────────

use hjkl_engine::Host;
use hjkl_vim::VimEditorExt;

impl super::App {
    /// `true` when the focused window's slot is the explorer buffer.
    pub(crate) fn explorer_buf_focused(&self) -> bool {
        let fw = self.focused_window();
        self.windows
            .get(fw)
            .and_then(|w| w.as_ref())
            .is_some_and(|w| self.slots.get(w.slot).is_some_and(|s| s.is_explorer()))
    }

    /// `<leader>e` toggle: closed → open + focus; open → close.
    ///
    /// Session-only: this does NOT write `explorer.open` back to the user's
    /// config. That key is the STARTUP preference, owned by the user through
    /// `:set explorer.open=…` (see `dock::App::set_explorer_open`) and read
    /// once by `dock::App::restore_dock_state_from_config`. Toggling the dock
    /// during a session is not a statement about how the next one should start,
    /// and writing it on every toggle meant the last toggle before quitting
    /// silently rewrote a preference the user had set deliberately.
    pub(crate) fn toggle_explorer(&mut self) {
        if self.tabs[self.active_tab].explorer.is_some() {
            self.close_explorer();
        } else {
            self.open_explorer();
        }
    }

    /// Open the explorer window as the left dock (#63 Phase A — outside
    /// every tab's `LayoutTree`, visible on every tab).
    fn open_explorer(&mut self) {
        use hjkl_buffer::View;
        use hjkl_engine::{BufferEdit, Settings};

        // Capture the file the user was editing so we can reveal it.
        let active_file = self.active().filename.clone();

        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let mut tree = ExplorerTree::new(cwd);

        // Reveal the active file's path before rendering, so the initial cursor
        // lands on it and its ancestor dirs are expanded. `reveal` is robust to
        // relative-vs-absolute path forms (it canonicalizes), and returns `None`
        // when the file isn't under the root — so no `starts_with` pre-gate (that
        // wrongly rejected files opened with a relative path, since cwd is
        // absolute).
        let reveal_row: Option<usize> = active_file.as_deref().and_then(|p| tree.reveal(p));

        let text = tree.render_text();
        // Compute initial folds from the `expanded` set (only root is open).
        let initial_folds = tree.compute_folds();
        // Nodes are rebuilt by new() above; no extra rebuild needed.

        let buffer_id = self.next_buffer_id;
        self.next_buffer_id += 1;

        // No editor built here (#151 Stage 2b — see `build_slot`'s doc): the
        // register/marks/search/change-bank Arcs and the viewport are wired
        // onto the window editor by `reconcile_window_editors` below, and
        // the reveal cursor is applied to the real window editor afterward
        // via `set_explorer_window_cursor`.
        let mut view = View::new();
        if !text.is_empty() {
            BufferEdit::replace_all(&mut view, &text);
        }
        // Apply initial folds so collapsed dirs hide their subtrees on open.
        view.set_folds(&initial_folds);
        // Settings for the explorer: no line numbers, no sign column, cursorline on.
        let settings = Settings {
            filetype: "explorer".to_string(),
            number: false,
            relativenumber: false,
            signcolumn: hjkl_engine::types::SignColumnMode::No,
            cursorline: true,
            foldcolumn: 0,
            ..Settings::default()
        };

        let slot = super::BufferSlot {
            kind: super::BufKind::Explorer,
            features: super::BufferFeatures {
                syntax: false,
                lsp: false,
                hover: false,
                end_of_buffer: false,
            },
            ..super::BufferSlot::new(buffer_id, view, settings)
        };
        self.slots.push(slot);
        let slot_idx = self.slots.len() - 1;

        // Save the outgoing window's cursor/scroll before changing focus.
        self.sync_viewport_from_editor();

        // Install as the ACTIVE TAB's left dock — an ordinary leaf of that
        // tab's `LayoutTree`, woven in as the full-height leftmost column.
        // Its width comes from config (as the split's `Fixed` allocation),
        // not a ratio, so it survives every resize unchanged.
        let new_win_id = self.install_left_dock(slot_idx, super::dock::DockKind::Explorer);

        // Focus the new explorer window.
        self.set_focused_window(new_win_id);
        self.reconcile_window_editors();
        self.sync_viewport_to_editor();

        // Build the initial baseline from the freshly-built tree nodes.
        // Each entry gets its sequence id = its index in tree.nodes.
        let initial_baseline: Vec<(u64, PathBuf, bool)> = tree
            .nodes
            .iter()
            .enumerate()
            .map(|(idx, n)| (idx as u64, n.path.clone(), n.is_dir))
            .collect();

        // Record the dirty_gen AFTER set_content so the first reconcile check
        // starts from the correct generation and doesn't fire immediately.
        let initial_gen = self.slots[slot_idx].buffer().dirty_gen();

        self.tabs[self.active_tab].explorer = Some(ExplorerPane {
            win_id: new_win_id,
            tree,
            last_reconcile_gen: initial_gen,
            trashed: Vec::new(),
            baseline: initial_baseline,
            undo_stack: Vec::new(),
            redo_stack: Vec::new(),
            trash_root: hjkl_app::trash::TrashRoot::Xdg,
        });

        // Overlay any already-dirty open buffers onto the freshly-built tree
        // so an edited buffer is immediately colored without waiting for the
        // next git-sign poll cycle.
        self.refresh_explorer_git();

        // Apply the reveal cursor position if we found the active file.
        if let Some(row) = reveal_row {
            self.set_explorer_window_cursor(row, 0, None);
        }
    }

    /// Current slot index of the ACTIVE TAB's explorer scratch buffer.
    ///
    /// Derived from the pane's window rather than by scanning slots for
    /// [`BufKind::Explorer`](super::BufKind::Explorer): explorers are per-tab
    /// (#63 Phase 3), so two tabs with the explorer open mean two explorer
    /// slots and a scan would pick whichever comes first — silently rewriting
    /// the other tab's tree. `BufKind` answers "is this slot an explorer?",
    /// never "*which* explorer"; that question only the owning pane's window
    /// can answer. The window's `slot` field stays right across the slot
    /// re-indexing that `:bd`/`:bn` cause.
    fn explorer_slot_idx(&self) -> Option<usize> {
        let win_id = self.tabs[self.active_tab].explorer.as_ref()?.win_id;
        self.windows.get(win_id)?.as_ref().map(|w| w.slot)
    }

    /// Close the active tab's explorer dock and remove its slot. Unweaving the
    /// leaf (and collapsing its fixed split onto the main area) is
    /// `teardown_left_dock`'s job; this drops the pane state that goes with it
    /// and moves focus onto a regular window.
    fn close_explorer(&mut self) {
        if self.tabs[self.active_tab].explorer.take().is_none() {
            return;
        }
        self.teardown_left_dock();
        let target = self
            .editor_target_window()
            .or_else(|| self.layout().leaves().into_iter().next())
            .unwrap_or_else(|| self.focused_window());
        self.set_focused_window(target);
        self.sync_viewport_to_editor();
    }

    /// Rebuild the explorer buffer text (after expand/collapse). Keeps the
    /// cursor row on the same path when possible.
    ///
    /// This is a full structural reset: it calls `set_content` (which clears
    /// undo history) and then syncs `baseline` and `last_reconcile_gen` so
    /// the next reconcile starts from the new content — not from the
    /// pre-toggle state. Undo across a fold-toggle is not expected.
    pub(crate) fn explorer_rebuild_buffer(&mut self) {
        let Some(slot_idx) = self.explorer_slot_idx() else {
            return;
        };
        let (text, win_id) = match self.tabs[self.active_tab].explorer.as_ref() {
            Some(ep) => (ep.tree.render_text(), ep.win_id),
            None => return,
        };
        // Stash the path currently under cursor before we rebuild.
        let prev_row = self.window_cursor(win_id).0;
        let prev_path = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .and_then(|ep| ep.tree.nodes.get(prev_row))
            .map(|n| n.path.clone());

        // Write new content directly (bypasses readonly guard intentionally).
        // set_content clears undo/redo — correct for structural nav (toggle/root).
        self.slots[slot_idx].set_content(&text);
        let _ = self.slots[slot_idx].take_content_edits();
        let _ = self.slots[slot_idx].take_content_reset();

        // Apply folds derived from the current `expanded` set so collapsed dirs
        // hide their subtrees. Must happen after set_content so row indices are
        // correct for the new text. `set_folds` is idempotent when unchanged.
        let folds = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .map(|ep| ep.tree.compute_folds())
            .unwrap_or_default();
        self.slots[slot_idx].buffer_mut().set_folds(&folds);
        self.sync_explorer_window_folds();

        // Sync the baseline from the freshly-rendered tree so the next
        // reconcile diffs against the post-toggle state. Also update
        // last_reconcile_gen to the new dirty_gen so the structural reset
        // doesn't trigger a spurious reconcile.
        let new_baseline: Vec<(u64, PathBuf, bool)> = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .map(|ep| {
                ep.tree
                    .nodes
                    .iter()
                    .enumerate()
                    .map(|(idx, n)| (idx as u64, n.path.clone(), n.is_dir))
                    .collect()
            })
            .unwrap_or_default();
        let new_gen = self.slots[slot_idx].buffer().dirty_gen();
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.baseline = new_baseline;
            ep.last_reconcile_gen = new_gen;
        }

        // Try to keep cursor on the same path.
        let new_row = if let Some(ref p) = prev_path {
            self.tabs[self.active_tab]
                .explorer
                .as_ref()
                .and_then(|ep| ep.tree.nodes.iter().position(|n| &n.path == p))
                .unwrap_or(prev_row)
        } else {
            prev_row
        };
        let clamped = new_row.min(
            self.tabs[self.active_tab]
                .explorer
                .as_ref()
                .map_or(0, |ep| ep.tree.nodes.len().saturating_sub(1)),
        );
        self.set_explorer_window_cursor(clamped, 0, None);
    }

    /// When the explorer is open and in Normal mode and the buffer has changed
    /// since the last reconcile, diff the buffer against the pane's explicit
    /// `baseline` and apply the resulting filesystem ops.
    ///
    /// **Git-aware rebuild model**: after applying ops the tree is rebuilt from
    /// disk + git so that tracked-but-deleted files reappear in red
    /// (git WT_DELETED injection). The buffer is fully reset via
    /// `explorer_rebuild_buffer` → `set_content`, which clears vim's undo
    /// stack. Explicit undo/redo is provided by the pane's `undo_stack` /
    /// `redo_stack` journal (see [`App::explorer_undo`] /
    /// [`App::explorer_redo`]).
    pub(crate) fn maybe_reconcile_explorer(&mut self) {
        use hjkl_engine::VimMode;

        // Nothing to do if the explorer is not open.
        if self.tabs[self.active_tab].explorer.is_none() {
            return;
        }

        // Find the explorer slot index.
        let Some(slot_idx) = self.explorer_slot_idx() else {
            return;
        };

        // Must be in Normal mode — don't reconcile mid-edit. The mode lives on
        // the explorer window's editor (#151 Phase D). When no window editor
        // exists yet (e.g. unit tests that drive the explorer without running
        // the reconcile tick — #151 Stage 2b: there's no slot-level editor to
        // fall back to anymore), default to Normal — an editor that was never
        // dispatched is always at rest in Normal mode, which is exactly what
        // the old slot-bridge-editor fallback always read too.
        let vim_mode = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .and_then(|e| self.window_editors.get(&e.win_id))
            .map_or(VimMode::Normal, |ed| ed.vim_mode());
        if vim_mode != VimMode::Normal {
            return;
        }

        // Read the buffer's current dirty_gen.
        let cur_gen = self.slots[slot_idx].buffer().dirty_gen();

        // Guard: nothing changed since last reconcile.
        let last_gen = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .map_or(0, |ep| ep.last_reconcile_gen);
        if cur_gen == last_gen {
            return;
        }

        // Clone what we need out of the tab's explorer pane before borrowing
        // self mutably.
        let (baseline, text, root) = {
            let ep = self.tabs[self.active_tab].explorer.as_ref().unwrap();
            let baseline = ep.baseline.clone();
            let text = self.slots[slot_idx].buffer().as_string();
            let root = ep.tree.root.clone();
            (baseline, text, root)
        };

        let ops = super::explorer_reconcile::reconcile(&baseline, &text, &root);

        if ops.is_empty() {
            // No fs changes. But the buffer may still hold cosmetic cruft that
            // parsed to nothing — most commonly a stray blank line left by
            // `o<Esc>` / `O<Esc>` (open-line with no name typed). If the text no
            // longer matches the tree's canonical render, rebuild to normalize
            // it away; otherwise just advance the gen (a pure cursor move on
            // unchanged content).
            let canonical = self.tabs[self.active_tab]
                .explorer
                .as_ref()
                .map(|ep| ep.tree.render_text())
                .unwrap_or_default();
            if text != canonical {
                self.explorer_rebuild_buffer(); // also syncs baseline + gen
            } else if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
                ep.last_reconcile_gen = cur_gen;
            }
            return;
        }

        // Take the trashed registry out of the pane so we can mutate it, and
        // copy the trash root out with it (the pane is borrowed again below).
        let (mut trashed, trash_root) = self.tabs[self.active_tab]
            .explorer
            .as_mut()
            .map(|ep| (std::mem::take(&mut ep.trashed), ep.trash_root.clone()))
            .unwrap_or_default();

        let (newly_created, applied, errors) =
            super::explorer_reconcile::apply_ops(&ops, &mut trashed, &trash_root);

        // Paths produced by this transaction (created / pasted-moved / renamed
        // destinations) — used below to land the cursor on the result (the
        // topmost when several), e.g. moving onto a pasted file.
        let result_paths: Vec<PathBuf> = {
            use super::explorer_reconcile::AppliedOp;
            applied
                .iter()
                .filter_map(|op| match op {
                    AppliedOp::Created(p) => Some(p.clone()),
                    AppliedOp::Restored { to, .. } => Some(to.clone()),
                    AppliedOp::Renamed { to, .. } => Some(to.clone()),
                    AppliedOp::Trashed { .. } => None,
                })
                .collect()
        };

        // Put the trashed registry back.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.trashed = trashed;
        }

        // Toast any errors.
        for err in &errors {
            self.bus.error(format!("explorer: {err}"));
        }

        // Keep open buffers consistent with what just happened on disk:
        // renamed files/dirs retarget their slots, trashed files flag theirs.
        self.retarget_slots_after_fs_ops(&applied);

        // Push to undo stack if ops actually happened; clear redo stack.
        if !applied.is_empty()
            && let Some(ep) = self.tabs[self.active_tab].explorer.as_mut()
        {
            ep.undo_stack.push(applied);
            ep.redo_stack.clear();
        }

        // Reveal newly-created nested items: expand the ancestor directories of
        // every result path so a multi-level create (`somedir/test.txt`) shows
        // the new leaf rather than leaving `somedir` collapsed. Walk each result
        // path's parents up to (and including) the root.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            let root = ep.tree.root.clone();
            for p in &result_paths {
                if !p.starts_with(&root) {
                    continue;
                }
                let mut dir = p.parent();
                while let Some(d) = dir {
                    ep.tree.set_expanded(d, true);
                    if d == root {
                        break;
                    }
                    dir = d.parent();
                }
            }
        }

        // Rebuild the tree from disk + git so that tracked-but-deleted files
        // appear as red nodes (WT_DELETED injection in push_children).
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.tree.rebuild();
        }
        // Reset the buffer content from the freshly-rebuilt tree and sync
        // baseline + last_reconcile_gen so the next tick is a no-op.
        self.explorer_rebuild_buffer();
        // Refresh git colors after rebuild.
        self.recompute_explorer_git_base();

        // Land the cursor on the transaction result (topmost created/pasted/
        // renamed path), overriding the sticky-cursor restore — so after a
        // paste you're on the pasted file.
        if !result_paths.is_empty() {
            let target = self.tabs[self.active_tab].explorer.as_ref().and_then(|ep| {
                ep.tree
                    .nodes
                    .iter()
                    .position(|n| result_paths.contains(&n.path))
                    .map(|row| (ep.win_id, row))
            });
            if let Some((_win_id, row)) = target {
                self.set_explorer_window_cursor(row, 0, None);
            }
        }

        // Spawn a background buffer for each newly-created file WITHOUT focusing
        // it — `open_new_slot` just builds the slot (no window/focus change), so
        // a long restructure session stays in the explorer. Skip files already
        // open in a slot.
        for path in newly_created {
            let already = self
                .slots
                .iter()
                .any(|s| s.filename.as_deref() == Some(path.as_path()));
            if !already {
                let _ = self.open_new_slot(path);
            }
        }
    }

    /// After a reconcile transaction touched disk, keep open buffers
    /// consistent with it:
    /// - `Renamed{from,to}`: any slot whose file IS `from`, or lives UNDER a
    ///   renamed directory `from`, is retargeted to the rebased path — so the
    ///   next `:w` writes the new location instead of silently recreating the
    ///   old path (which would fork the file).
    /// - `Trashed{original,..}`: a slot open on the trashed path (or under a
    ///   trashed directory) keeps its buffer + content and is flagged
    ///   [`super::DiskState::DeletedOnDisk`] with a one-shot warning —
    ///   mirroring the fs-watch/checktime delete path. An explicit `:w`
    ///   re-creates the file (vim semantics for an externally-deleted file).
    ///
    /// Ops are processed in journal order so multi-step transactions (the
    /// temp hop `a→tmp, b→a, tmp→b` of a name swap) resolve each slot to its
    /// correct final path.
    fn retarget_slots_after_fs_ops(&mut self, applied: &[super::explorer_reconcile::AppliedOp]) {
        use super::explorer_reconcile::AppliedOp;
        let mut any_rename = false;
        for op in applied {
            match op {
                AppliedOp::Renamed { from, to } => {
                    // Component-wise prefix match (Path::strip_prefix), NOT a
                    // string prefix — renaming dir `d` must rebase `d/inner.txt`
                    // but never the sibling `dx.txt`.
                    let targets: Vec<(usize, PathBuf)> = self
                        .slots
                        .iter()
                        .enumerate()
                        .filter_map(|(i, s)| {
                            if s.is_explorer() {
                                return None;
                            }
                            let p = s.filename.as_deref()?;
                            let rel = super::canon_for_match(p)
                                .strip_prefix(from)
                                .ok()?
                                .to_path_buf();
                            let new_path = if rel.as_os_str().is_empty() {
                                to.clone()
                            } else {
                                to.join(rel)
                            };
                            Some((i, new_path))
                        })
                        .collect();
                    for (i, new_path) in targets {
                        self.retarget_slot_file(i, new_path);
                        any_rename = true;
                    }
                }
                AppliedOp::Trashed { original, .. } => {
                    for i in 0..self.slots.len() {
                        if self.slots[i].is_explorer() {
                            continue;
                        }
                        let Some(p) = self.slots[i].filename.clone() else {
                            continue;
                        };
                        if super::canon_for_match(&p).strip_prefix(original).is_err() {
                            continue;
                        }
                        // Keep the buffer + content; flag + warn once — the
                        // same treatment checktime gives an external delete.
                        let prev = self.slots[i].disk_state;
                        self.slots[i].disk_state = super::DiskState::DeletedOnDisk;
                        if prev != super::DiskState::DeletedOnDisk {
                            self.bus.warn(format!(
                                "W: \"{}\" deleted on disk (moved to trash)",
                                p.display()
                            ));
                        }
                    }
                }
                _ => {}
            }
        }
        if any_rename {
            // Watch the new parent dirs / drop watches on the old ones.
            self.fs_watch_sync();
        }
    }

    /// Point slot `idx` at `new_path`, migrating every piece of state that
    /// hangs off the filename — the same set `:saveas` (`save_slot` with a
    /// new path) maintains, plus the pieces armed at open time:
    /// - LSP: `didClose` the old URI before the identity changes, `didOpen`
    ///   the new one after (the detach/attach pair `restart_lsp` uses).
    /// - swap: remove the old path's swap file, derive + re-arm the new one.
    /// - `"%` register, git re-probe, disk mtime/len baseline.
    /// - syntax: re-detect the language (the extension may have changed).
    fn retarget_slot_file(&mut self, idx: usize, new_path: PathBuf) {
        self.lsp_detach_buffer(idx);

        if let Some(old_swap) = self.slots[idx].swap_path.take() {
            let _ = hjkl_app::swap::remove_swap(&old_swap);
        }
        self.slots[idx].last_swap_dirty_gen = None;
        let canonical = std::fs::canonicalize(&new_path).unwrap_or_else(|_| new_path.clone());
        self.slots[idx].swap_path = hjkl_app::swap::swap_path_in(&self.swap_root, &canonical).ok();

        self.slots[idx].filename = Some(new_path.clone());
        self.slots[idx].git_repo_present = None; // re-probe for new path
        // `%` register — session-shared Arc, same one every editor (window or
        // otherwise) is wired to, so writing it directly needs no live editor
        // (#151 Stage 2b).
        self.registers
            .lock()
            .unwrap()
            .set_filename(Some(new_path.to_string_lossy().into_owned()));
        // The disk baseline follows the file to its new path (content is
        // unchanged by a rename; the metadata identity is what moved).
        if let Ok(meta) = std::fs::metadata(&new_path) {
            self.slots[idx].disk_mtime = meta.modified().ok();
            self.slots[idx].disk_len = Some(meta.len());
        }

        // Language may change with the extension (`notes.txt` → `notes.rs`).
        // Content is unchanged by a rename, so the buffer is the content
        // source for the seam — an extensionless target with a shebang or
        // modeline `ft=` detects correctly too.
        let content = self.slots[idx].buffer().content_joined();
        self.redetect_language_for_slot(idx, &content);
        if idx == self.focused_slot_idx() {
            self.pending_recompute = true;
        }

        self.lsp_attach_buffer(idx);
        self.arm_swap_on_open(idx);
    }

    /// Undo the last explorer filesystem transaction.
    ///
    /// Pops one entry from `undo_stack`, reverses it on disk via
    /// [`super::explorer_reconcile::revert_ops`], pushes the redo journal onto
    /// `redo_stack`, and rebuilds the buffer so the tree reflects the reverted
    /// state.  Returns `true` when something was undone.
    ///
    /// After this call `last_reconcile_gen` matches the new buffer gen so the
    /// subsequent `maybe_reconcile_explorer` tick is a no-op.
    pub(crate) fn explorer_undo(&mut self) -> bool {
        // Pop the top transaction from the undo stack.
        let txn = {
            let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() else {
                return false;
            };
            match ep.undo_stack.pop() {
                Some(t) => t,
                None => return false,
            }
        };

        // Take the trashed registry (and the trash root alongside it).
        let (mut trashed, trash_root) = self.tabs[self.active_tab]
            .explorer
            .as_mut()
            .map(|ep| (std::mem::take(&mut ep.trashed), ep.trash_root.clone()))
            .unwrap_or_default();

        let (redo_journal, errors) =
            super::explorer_reconcile::revert_ops(&txn, &mut trashed, &trash_root);

        // Put the trashed registry back and push to redo stack.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.trashed = trashed;
            ep.redo_stack.push(redo_journal);
        }

        for err in &errors {
            self.bus.error(format!("explorer undo: {err}"));
        }

        // Rebuild tree + buffer from disk + git.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.tree.rebuild();
        }
        self.explorer_rebuild_buffer();
        self.recompute_explorer_git_base();

        true
    }

    /// Redo the last undone explorer filesystem transaction.
    ///
    /// Pops one entry from `redo_stack`, re-applies it via
    /// [`super::explorer_reconcile::apply_applied`], pushes the new applied
    /// journal back onto `undo_stack`, and rebuilds the buffer.
    pub(crate) fn explorer_redo(&mut self) {
        // Pop the top transaction from the redo stack.
        let redo_txn = {
            let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() else {
                return;
            };
            match ep.redo_stack.pop() {
                Some(t) => t,
                None => return,
            }
        };

        // Take the trashed registry (and the trash root alongside it).
        let (mut trashed, trash_root) = self.tabs[self.active_tab]
            .explorer
            .as_mut()
            .map(|ep| (std::mem::take(&mut ep.trashed), ep.trash_root.clone()))
            .unwrap_or_default();

        let (_newly_created, new_applied, errors) =
            super::explorer_reconcile::apply_applied(&redo_txn, &mut trashed, &trash_root);

        // Put the trashed registry back and push to undo stack.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.trashed = trashed;
            if !new_applied.is_empty() {
                ep.undo_stack.push(new_applied);
            }
        }

        for err in &errors {
            self.bus.error(format!("explorer redo: {err}"));
        }

        // Rebuild tree + buffer from disk + git.
        if let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() {
            ep.tree.rebuild();
        }
        self.explorer_rebuild_buffer();
        self.recompute_explorer_git_base();
    }

    /// Copy the explorer buffer's CURRENT fold state into its per-window
    /// `window_folds` snapshot.
    ///
    /// Window-level folds (the multi-split feature) render an UNFOCUSED window
    /// from `app.window_folds[win]`, NOT from the live buffer — but the explorer
    /// mutates its buffer folds directly (`toggle_fold_at`, `reveal_row`,
    /// `set_folds`) while it is usually unfocused. Without this the snapshot
    /// goes stale: `BufferView` keeps drawing the old fold layout while the
    /// glyph/color overlay (which reads the live buffer) paints the new one, so
    /// the two desync and the tree renders garbled. Call after every explorer
    /// fold mutation.
    pub(crate) fn sync_explorer_window_folds(&mut self) {
        let Some(win_id) = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .map(|ep| ep.win_id)
        else {
            return;
        };
        let Some(slot_idx) = self.explorer_slot_idx() else {
            return;
        };
        let folds = self.slots[slot_idx].buffer().folds();
        self.window_folds.insert(win_id, folds);
    }

    /// Set the explorer window editor's cursor (and optionally scroll top)
    /// directly (#151 Phase D, B' step 2). The per-window editor is the single
    /// source of truth — callers pass the target row/col instead of staging it
    /// in the now-removed `layout::Window` cursor mirror. `top = None` leaves
    /// the current scroll.
    ///
    /// Falls back to setting the slot's own `View` cursor (#151 Stage 2b — no
    /// slot bridge editor anymore) when no window editor exists yet — e.g. a
    /// unit test driving the explorer without a reconcile tick. That cursor
    /// becomes the initial cursor the next `make_view_editor` call picks up
    /// for this slot. `top` is dropped in that fallback (no viewport to set):
    /// the only caller passing `Some` already reads a live window editor's
    /// viewport to compute it, so it can never hit this branch.
    pub(crate) fn set_explorer_window_cursor(
        &mut self,
        row: usize,
        col: usize,
        top: Option<usize>,
    ) {
        let Some(win_id) = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .map(|e| e.win_id)
        else {
            return;
        };
        let Some(slot_idx) = self.explorer_slot_idx() else {
            return;
        };
        match self.window_editors.get_mut(&win_id) {
            Some(editor) => {
                editor.jump_cursor(row, col);
                if let Some(t) = top {
                    editor.host_mut().viewport_mut().top_row = t;
                }
            }
            None => {
                let clamped = self.slots[slot_idx]
                    .buffer()
                    .clamp_position(hjkl_buffer::Position::new(row, col));
                self.slots[slot_idx].buffer_mut().set_cursor(clamped);
            }
        }
    }

    /// "Follow" the active buffer in the explorer: when the explorer is open,
    /// reveal the active buffer's file (expand its ancestors) and move the
    /// explorer's selection to it — so the buffer you're editing is the
    /// highlighted row in the tree. Does NOT change window focus. No-op for
    /// scratch buffers or files outside the tree root.
    pub(crate) fn explorer_reveal_active(&mut self) {
        if self.tabs[self.active_tab].explorer.is_none() {
            return;
        }
        let Some(fname) = self.active().filename.clone() else {
            return;
        };
        // Tree nodes are absolute (root = cwd); the active filename may be cwd-
        // relative. Resolve to absolute before matching.
        let abs = if fname.is_absolute() {
            fname
        } else {
            std::env::current_dir()
                .map(|c| c.join(&fname))
                .unwrap_or(fname)
        };

        let Some(slot_idx) = self.explorer_slot_idx() else {
            return;
        };

        // Locate the target row, syncing ancestor `expanded` state. `reveal`
        // rebuilds the tree from disk ONLY when the node is missing (e.g. a
        // freshly-created file), which changes the node count.
        let (win_id, row, structural) = {
            let Some(ep) = self.tabs[self.active_tab].explorer.as_mut() else {
                return;
            };
            if !abs.starts_with(&ep.tree.root) {
                return; // file isn't under the explorer's root
            }
            let before = ep.tree.nodes.len();
            let row = ep.tree.reveal(&abs);
            let structural = ep.tree.nodes.len() != before;
            (ep.win_id, row, structural)
        };

        if structural {
            // The node list changed (new file appeared): rebuild the buffer so
            // it matches, applying folds from the now-updated `expanded` set
            // (ancestors opened by `reveal`). Only paid on the rare new-file
            // path — NOT on every buffer switch.
            self.explorer_rebuild_buffer();
            self.recompute_explorer_git_base();
        }

        let Some(row) = row else {
            return;
        };

        if !structural {
            // Common case: open ONLY the folds enclosing the target row in the
            // buffer, leaving every other dir's fold state intact. This avoids
            // the full `set_content` + fold-recompute reset that collapsed
            // search-revealed dirs, hid the target row (cursorline landed on
            // the wrong line), and reshuffled the scroll position.
            self.slots[slot_idx].buffer_mut().reveal_row(row);
            self.sync_explorer_window_folds();
        }

        // Move the explorer selection to the revealed row and scroll it into
        // view ONLY if it isn't already visible. The check must be FOLD-AWARE:
        // collapsed dirs above the cursor make the raw row index much larger
        // than its on-screen position, so a naive `row >= top + height` compare
        // wrongly scrolled the pane every time a (clearly visible) file was
        // clicked. Walk visible rows from `top_row` instead.
        let cur_top = self.window_scroll(win_id).0;
        let height = self
            .windows
            .get(win_id)
            .and_then(|w| w.as_ref())
            .and_then(|w| w.last_rect)
            .map_or(0, |rc| rc.h as usize);
        let new_top = if height == 0 {
            cur_top
        } else if row < cur_top {
            row
        } else {
            // Is `row` among the `height` visible rows starting at `cur_top`?
            let buf = self.slots[slot_idx].buffer();
            let mut r = cur_top;
            let mut visible = false;
            for _ in 0..height {
                if r == row {
                    visible = true;
                    break;
                }
                match buf.next_visible_row(r) {
                    Some(n) => r = n,
                    None => break,
                }
            }
            if visible {
                cur_top
            } else {
                // Off the bottom: make `row` the last visible line by walking
                // back `height - 1` visible rows from it.
                let mut t = row;
                for _ in 0..height.saturating_sub(1) {
                    match buf.prev_visible_row(t) {
                        Some(p) => t = p,
                        None => break,
                    }
                }
                t
            }
        };
        // Set the explorer window editor cursor + scroll directly (#151 Phase D).
        self.set_explorer_window_cursor(row, 0, Some(new_top));
    }

    /// Enter/l/o on the explorer: toggle dir or open file.
    pub(crate) fn explorer_activate(&mut self) {
        // Determine the cursor row in the explorer window (its own editor).
        let cursor_row = {
            let wid = self.tabs[self.active_tab].explorer.as_ref().unwrap().win_id;
            self.window_cursor(wid).0
        };

        // Get the node at cursor.
        let node = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .and_then(|ep| ep.tree.nodes.get(cursor_row))
            .cloned();

        let Some(node) = node else { return };

        if node.is_dir {
            // Lazy expand/collapse: flip the dir's `expanded` state and rebuild.
            // Expanding reads that dir's one level from disk (instant); collapsing
            // drops its subtree from `nodes`. `explorer_rebuild_buffer` re-renders
            // and keeps the cursor on this dir's path.
            let path = node.path;
            if let Some(ref mut ep) = self.tabs[self.active_tab].explorer {
                let now = ep.tree.is_expanded(&path);
                ep.tree.set_expanded(&path, !now);
                ep.tree.rebuild();
            }
            self.explorer_rebuild_buffer();
        } else {
            // File: open in a regular editor window (prefers the last one
            // the user focused — see `editor_target_window`).
            self.focus_editor_window_for_open();
            let s = Self::explorer_open_arg(&node.path);
            // Suppress the `"file" NL` open toast — opening from the tree is an
            // explicit action and the buffer visibly changes.
            self.suppress_open_notice = true;
            self.dispatch_ex(&format!("edit {s}"));
            self.suppress_open_notice = false;
        }
    }

    /// `o`/`O` when the cursor is on a DIRECTORY: create the new entry as a
    /// CHILD of that dir (not a sibling). Opens the dir's fold so the new line
    /// sits right under it, runs the normal open-line-below, then indents the
    /// fresh line to child depth so reconcile resolves the parent to this dir.
    /// Returns `false` (not handled) when the cursor isn't on a directory — the
    /// caller then lets the normal `o`/`O` create a sibling.
    pub(crate) fn explorer_open_in_dir(&mut self) -> bool {
        let node = match self.explorer_cursor_node() {
            Some(n) if n.is_dir => n,
            _ => return false,
        };
        let Some(_slot_idx) = self.explorer_slot_idx() else {
            return false;
        };
        // Expand the dir (lazy-load its one level) so the new line lands as its
        // first child, then re-render. If already expanded this is a cheap
        // re-read. `explorer_rebuild_buffer` keeps the cursor on the dir row.
        if let Some(ref mut ep) = self.tabs[self.active_tab].explorer
            && !ep.tree.is_expanded(&node.path)
        {
            ep.tree.set_expanded(&node.path, true);
            ep.tree.rebuild();
        }
        self.explorer_rebuild_buffer();
        // Open a line below + enter insert (autoindent copies the dir's indent).
        // Dispatch to the focused window's editor (#151 Phase D / #252) — that's
        // the one render + reconcile read; the slot editor is just a content
        // bridge and is never the dispatched editor.
        hjkl_vim_tui::handle_key(
            self.active_editor_mut(),
            crossterm::event::KeyEvent::new(
                crossterm::event::KeyCode::Char('o'),
                crossterm::event::KeyModifiers::NONE,
            ),
        );
        // Indent the new line to CHILD depth (dir.depth + 1) so reconcile nests
        // it under this dir. Pad from the current cursor column (robust whether
        // or not autoindent already copied the dir's indent).
        let target_col = (node.depth + 1) * 2 + 2;
        let col = self.active_editor().cursor().1;
        if col < target_col {
            let pad = " ".repeat(target_col - col);
            self.active_editor_mut().insert_str(&pad);
        }
        true
    }

    /// `p` when the cursor is on a DIRECTORY: paste the cut/yanked tree lines
    /// INSIDE that dir (re-indented as its children) so `dd` a dir/file then
    /// `p` on another dir MOVES the entry into it. The pasted lines have their
    /// `<US><id>` tails stripped, so reconcile treats them as fresh creates —
    /// files are restored from trash by name (contents preserved), making the
    /// whole thing a real move.
    ///
    /// Returns `false` (not handled) when the cursor isn't on a directory or the
    /// register isn't a linewise tree block — the caller then runs the normal
    /// sibling paste.
    pub(crate) fn explorer_paste_in_dir(&mut self) -> bool {
        use super::explorer_reconcile::ID_SEP;
        // Guard: explorer slot must exist. Editing goes through the focused
        // window's editor (#151 Phase D / #252), not the slot bridge.
        if self.explorer_slot_idx().is_none() {
            return false;
        }
        let cursor_row = self.active_editor().cursor().0;
        // Node under the editor cursor must be a directory.
        let node = match self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .and_then(|ep| ep.tree.nodes.get(cursor_row))
            .cloned()
        {
            Some(n) if n.is_dir => n,
            _ => return false,
        };

        // Register must hold a linewise tree block (from `dd`/`yy` of rows).
        let slot = self.active_editor().with_registers(|r| r.unnamed.clone());
        if !slot.linewise || slot.text.trim().is_empty() {
            return false;
        }

        // Strip the id tail from each line + drop blanks.
        let lines: Vec<String> = slot
            .text
            .trim_end_matches('\n')
            .split('\n')
            .map(|l| l.split(ID_SEP).next().unwrap_or(l).to_string())
            .filter(|l| !l.trim().is_empty())
            .collect();
        if lines.is_empty() {
            return false;
        }

        // Re-indent so the block's shallowest line becomes a child of this dir
        // (depth+1); deeper lines keep their relative offset.
        let min_indent = lines
            .iter()
            .map(|l| l.len() - l.trim_start_matches(' ').len())
            .min()
            .unwrap_or(0);
        let target_indent = (node.depth + 1) * 2 + 2;
        let delta = target_indent as isize - min_indent as isize;
        let block: String = lines
            .iter()
            .map(|l| {
                let cur = l.len() - l.trim_start_matches(' ').len();
                let new_indent = (cur as isize + delta).max(0) as usize;
                format!("{}{}", " ".repeat(new_indent), &l[cur..])
            })
            .collect::<Vec<_>>()
            .join("\n");

        // Expand the dir (lazy-load its one level) so the paste lands as visible
        // children right after the dir line, not after a collapsed subtree.
        if let Some(ref mut ep) = self.tabs[self.active_tab].explorer
            && !ep.tree.is_expanded(&node.path)
        {
            ep.tree.set_expanded(&node.path, true);
            ep.tree.rebuild();
        }
        // `explorer_rebuild_buffer` keeps the editor cursor on the dir's row, so
        // `paste_after` below targets the dir's first-child position.
        self.explorer_rebuild_buffer();

        // Install the re-indented, id-less block as a linewise register and
        // paste below the dir line.
        self.active_editor_mut().with_registers_mut(|r| {
            r.unnamed = hjkl_engine::Slot {
                text: block,
                linewise: true,
                ..Default::default()
            };
        });
        self.active_editor_mut().paste_after(1);
        true
    }

    // NOTE: window targeting for opens lives on `App` now —
    // `editor_target_window` / `focus_editor_window_for_open` (mod.rs)
    // replaced the old `nearest_non_explorer_window`, adding last-focused
    // preference and cmdline-window exclusion.

    // ── Cursor-node helpers ───────────────────────────────────────────────────

    /// Return the node currently under the explorer cursor.
    fn explorer_cursor_node(&self) -> Option<ExplorerNode> {
        let ep = self.tabs[self.active_tab].explorer.as_ref()?;
        let row = self.window_cursor(ep.win_id).0;
        ep.tree.nodes.get(row).cloned()
    }

    /// Path string for an `:edit`/`:split`/… open command. Relative to the cwd
    /// when the file is under it (so buffer names match normally-opened files
    /// instead of showing absolute paths in the picker / buffer line), else
    /// absolute.
    fn explorer_open_arg(path: &Path) -> String {
        // Escape `%` / `#` — the returned string is interpolated into an ex
        // command line, where `dispatch_ex` would otherwise expand them to the
        // current / alternate filename (opening the wrong file).
        if let Ok(cwd) = std::env::current_dir()
            && let Ok(rel) = path.strip_prefix(&cwd)
        {
            return super::ex_dispatch::escape_ex_path(&rel.to_string_lossy());
        }
        super::ex_dispatch::escape_ex_path(&path.to_string_lossy())
    }

    // ── Refresh / hidden / root ───────────────────────────────────────────────

    /// Toggle dotfile visibility and rebuild.
    pub(crate) fn explorer_toggle_hidden(&mut self) {
        if let Some(ref mut ep) = self.tabs[self.active_tab].explorer {
            ep.tree.toggle_hidden();
        }
        self.explorer_rebuild_buffer();
    }

    /// Toggle git-ignore honoring (show/hide ignored entries) and rebuild.
    pub(crate) fn explorer_toggle_gitignore(&mut self) {
        if let Some(ref mut ep) = self.tabs[self.active_tab].explorer {
            ep.tree.toggle_gitignore();
        }
        self.explorer_rebuild_buffer();
    }

    /// Move the tree root up to its parent directory.
    pub(crate) fn explorer_root_up(&mut self) {
        let parent = self.tabs[self.active_tab]
            .explorer
            .as_ref()
            .and_then(|ep| ep.tree.root.parent().map(|p| p.to_path_buf()));
        if let Some(parent) = parent {
            if let Some(ref mut ep) = self.tabs[self.active_tab].explorer {
                ep.tree.set_root(parent);
            }
            self.explorer_rebuild_buffer();
        }
    }

    // ── Open modes ───────────────────────────────────────────────────────────

    /// Open the file under cursor in a horizontal split.
    pub(crate) fn explorer_open_split(&mut self) {
        let node = match self.explorer_cursor_node() {
            Some(n) if !n.is_dir => n,
            _ => return,
        };
        self.focus_editor_window_for_open();
        let s = Self::explorer_open_arg(&node.path);
        self.dispatch_ex(&format!("split {s}"));
    }

    /// Open the file under cursor in a vertical split.
    pub(crate) fn explorer_open_vsplit(&mut self) {
        let node = match self.explorer_cursor_node() {
            Some(n) if !n.is_dir => n,
            _ => return,
        };
        self.focus_editor_window_for_open();
        let s = Self::explorer_open_arg(&node.path);
        self.dispatch_ex(&format!("vsplit {s}"));
    }

    /// Open the file under cursor in a new tab.
    pub(crate) fn explorer_open_tab(&mut self) {
        let node = match self.explorer_cursor_node() {
            Some(n) if !n.is_dir => n,
            _ => return,
        };
        let s = Self::explorer_open_arg(&node.path);
        self.dispatch_ex(&format!("tabnew {s}"));
    }

    /// Route an explorer [`AppAction`](crate::keymap_actions::AppAction) to the
    /// corresponding `explorer_*` method. Called from `dispatch_action` when the
    /// match arm groups all `Explorer*` variants together.
    pub(crate) fn dispatch_explorer_action(&mut self, action: crate::keymap_actions::AppAction) {
        use crate::keymap_actions::AppAction;
        match action {
            AppAction::ExplorerActivate => self.explorer_activate(),
            AppAction::ExplorerOpenSplit => self.explorer_open_split(),
            AppAction::ExplorerOpenVsplit => self.explorer_open_vsplit(),
            AppAction::ExplorerOpenTab => self.explorer_open_tab(),
            AppAction::ExplorerRootUp => self.explorer_root_up(),
            AppAction::ExplorerToggleHidden => self.explorer_toggle_hidden(),
            AppAction::ExplorerToggleGitignore => self.explorer_toggle_gitignore(),
            AppAction::ExplorerGitStageToggle => self.explorer_git_stage_toggle(),
            AppAction::ExplorerGitDiscard => self.explorer_git_discard(),
            AppAction::ExplorerGitCommit => self.explorer_git_commit(),
            _ => {}
        }
    }

    // ── Git path operations ───────────────────────────────────────────────────

    /// `ga` — stage or unstage the node under the cursor.
    ///
    /// If the node's git status is [`ExplorerGit::Staged`] the path is
    /// unstaged; otherwise it is staged. After success the explorer git base is
    /// recomputed and the buffer is rebuilt so the gutter colours update
    /// immediately.
    pub(crate) fn explorer_git_stage_toggle(&mut self) {
        let Some(node) = self.explorer_cursor_node() else {
            return;
        };
        let Some(ep) = self.tabs[self.active_tab].explorer.as_ref() else {
            return;
        };
        if !ep.tree.repo_present {
            return;
        }
        let Some(root) = hjkl_app::git::repo_root(&node.path) else {
            return;
        };
        let result = if node.git == Some(hjkl_app::git::ExplorerGit::Staged) {
            hjkl_app::git::unstage_path(&root, &node.path)
        } else {
            hjkl_app::git::stage_path(&root, &node.path)
        };
        match result {
            Err(e) => {
                self.bus.error(format!("Git stage failed: {e}"));
            }
            Ok(()) => {
                self.recompute_explorer_git_base();
                self.explorer_rebuild_buffer();
                self.refresh_git_signs_force();
            }
        }
    }

    /// `gr` — open a confirm overlay to discard worktree changes for the node
    /// under the cursor.
    ///
    /// Only opens the confirm when the node has tracked worktree changes
    /// (Modified or Deleted). Untracked files are skipped — `git checkout`
    /// does not affect them and it would be a surprising no-op. The confirm
    /// prompt reads "Discard changes to <name>? (y/n)".
    pub(crate) fn explorer_git_discard(&mut self) {
        let Some(node) = self.explorer_cursor_node() else {
            return;
        };
        let Some(ep) = self.tabs[self.active_tab].explorer.as_ref() else {
            return;
        };
        if !ep.tree.repo_present {
            return;
        }
        // Only offer discard for paths that have tracked worktree changes.
        // Untracked → `git checkout` would fail / be a no-op; Staged → use `ga`
        // to unstage first. Directories with a rolled-up status (git == None)
        // also proceed — `git checkout -- <dir>` is recursive over tracked files.
        let allow = match node.git {
            Some(hjkl_app::git::ExplorerGit::Modified | hjkl_app::git::ExplorerGit::Deleted) => {
                true
            }
            None if node.is_dir => true,
            _ => false,
        };
        if !allow {
            return;
        }
        self.explorer_git_discard_confirm = Some(node.path);
    }

    /// Called when the user confirms a git-discard with `y`.
    pub(crate) fn explorer_commit_git_discard(&mut self) {
        let Some(path) = self.explorer_git_discard_confirm.take() else {
            return;
        };
        let Some(root) = hjkl_app::git::repo_root(&path) else {
            self.bus.error("Git discard failed: path not in a repo");
            return;
        };
        match hjkl_app::git::discard_path(&root, &path) {
            Ok(()) => {
                self.recompute_explorer_git_base();
                self.explorer_rebuild_buffer();
                self.refresh_git_signs_force();
            }
            Err(e) => {
                self.bus.error(format!("Git discard failed: {e}"));
            }
        }
    }

    /// `gc` — open COMMIT_EDITMSG in a split for committing staged changes.
    ///
    /// Resolves the explorer tree root as the git root. Opens the repo's
    /// `COMMIT_EDITMSG` file pre-filled with a comment template and
    /// `git status --short --branch`. On window close the commit hook in
    /// `close_focused_window` runs `git commit --cleanup=strip -F <msg_file>`.
    pub(crate) fn explorer_git_commit(&mut self) {
        // Resolve root from the explorer tree.
        let root = match self.tabs[self.active_tab].explorer.as_ref() {
            Some(ep) => {
                if !ep.tree.repo_present {
                    self.bus.warn("not a git repository");
                    return;
                }
                match hjkl_app::git::repo_root(&ep.tree.root) {
                    Some(r) => r,
                    None => {
                        self.bus.warn("not a git repository");
                        return;
                    }
                }
            }
            None => {
                self.bus.warn("not a git repository");
                return;
            }
        };

        let Some(msg_file) = hjkl_app::git::commit_edit_path(&root) else {
            self.bus.error("could not resolve COMMIT_EDITMSG path");
            return;
        };

        let template = hjkl_app::git::commit_template(&root);
        if let Err(e) = std::fs::write(&msg_file, &template) {
            self.bus
                .error(format!("could not write commit template: {e}"));
            return;
        }

        // Focus a regular editor window first (same as explorer_open_split).
        self.focus_editor_window_for_open();

        // Open the COMMIT_EDITMSG in a horizontal split.
        let s = Self::explorer_open_arg(&msg_file);
        self.dispatch_ex(&format!("split {s}"));

        // Find the newly focused slot (the split just opened) and attach ctx.
        let slot_idx = self.focused_slot_idx();
        self.slots[slot_idx].commit_ctx = Some(super::types::CommitCtx { root, msg_file });
    }

    /// Route a key when `explorer_git_discard_confirm` is active.
    pub(crate) fn handle_explorer_git_discard_confirm_key(
        &mut self,
        key: crossterm::event::KeyEvent,
    ) {
        use crossterm::event::KeyCode;
        match key.code {
            KeyCode::Char('y') | KeyCode::Char('Y') => {
                self.explorer_commit_git_discard();
            }
            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                self.explorer_git_discard_confirm = None;
            }
            _ => {} // consume but do nothing
        }
    }
}

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

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

    /// Build a unique temp dir tree:
    /// root/{ a_dir/{ inner.txt }, b_dir/, m_file.txt, z_file.txt }
    fn make_tree() -> PathBuf {
        // Unique per call: keying only on the pid made every parallel test that
        // calls make_tree() share one directory, so one test's opening
        // remove_dir_all wiped another's tree mid-run (spurious failures under
        // `cargo test`'s thread pool). A per-call counter isolates them.
        use std::sync::atomic::{AtomicU64, Ordering};
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let base =
            std::env::temp_dir().join(format!("hjkl_explorer_test_{}_{n}", std::process::id()));
        let _ = fs::remove_dir_all(&base);
        fs::create_dir_all(base.join("a_dir")).unwrap();
        fs::create_dir_all(base.join("b_dir")).unwrap();
        fs::write(base.join("a_dir").join("inner.txt"), "x").unwrap();
        fs::write(base.join("m_file.txt"), "x").unwrap();
        fs::write(base.join("z_file.txt"), "x").unwrap();
        base
    }

    /// Names of the non-root nodes.
    fn child_names(tree: &ExplorerTree) -> Vec<String> {
        tree.nodes[1..]
            .iter()
            .map(|n| n.path.file_name().unwrap().to_string_lossy().into_owned())
            .collect()
    }

    #[test]
    fn root_first_then_dirs_first_then_name() {
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        assert_eq!(tree.nodes[0].path, root);
        assert!(tree.nodes[0].is_dir && tree.nodes[0].depth == 0);
        // Lazy walk: only the top level is present (a_dir's `inner.txt` absent).
        let names = child_names(&tree);
        for top in ["a_dir", "b_dir", "m_file.txt", "z_file.txt"] {
            assert!(names.contains(&top.to_string()), "{top} must be present");
        }
        assert!(
            !names.contains(&"inner.txt".to_string()),
            "inner.txt is under a collapsed dir — must NOT be present yet; got {names:?}"
        );
        // Top-level order: dirs first (a_dir, b_dir), then files (m, z).
        let a_idx = names.iter().position(|n| n == "a_dir").unwrap();
        let b_idx = names.iter().position(|n| n == "b_dir").unwrap();
        let m_idx = names.iter().position(|n| n == "m_file.txt").unwrap();
        assert!(
            a_idx < b_idx && b_idx < m_idx,
            "dirs-first order, got {names:?}"
        );

        // Expand a_dir → its child appears in DFS order, right after a_dir.
        let a_path = tree.nodes[1].path.clone();
        tree.set_expanded(&a_path, true);
        tree.rebuild();
        let names = child_names(&tree);
        let a_idx = names.iter().position(|n| n == "a_dir").unwrap();
        let i_idx = names.iter().position(|n| n == "inner.txt").unwrap();
        let b_idx = names.iter().position(|n| n == "b_dir").unwrap();
        assert!(
            a_idx < i_idx && i_idx < b_idx,
            "DFS after expand: a_dir < inner.txt < b_dir, got {names:?}"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn expand_inserts_children_at_depth_with_guide() {
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        // Lazy: inner.txt absent until a_dir is expanded.
        assert!(!tree.nodes.iter().any(|n| n.path.ends_with("inner.txt")));
        let a_path = tree.nodes[1].path.clone();
        tree.set_expanded(&a_path, true);
        tree.rebuild();
        // a_dir = nodes[1], inner.txt = nodes[2] at depth 2 with a guide.
        let inner = &tree.nodes[2];
        assert_eq!(inner.depth, 2);
        assert!(!inner.is_dir);
        assert_eq!(inner.branches, vec![true]);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn last_child_flag() {
        let root = make_tree();
        let tree = ExplorerTree::new(root.clone());
        assert!(!tree.nodes[1].is_last); // a_dir is NOT last (b_dir, files follow)
        let z = tree.nodes.last().unwrap();
        assert_eq!(z.path.file_name().unwrap(), "z_file.txt");
        assert!(z.is_last);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn collapse_removes_children_from_nodes() {
        // Lazy model: expand/collapse adds/removes the dir's children from
        // `nodes` (no buffer folds — the node list IS the visible tree).
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        let a_dir_path = tree.nodes[1].path.clone();
        let has_inner = |t: &ExplorerTree| t.nodes.iter().any(|n| n.path.ends_with("inner.txt"));

        // a_dir starts collapsed → inner.txt absent.
        assert!(!has_inner(&tree), "child of collapsed dir must be absent");

        // Expand → child node appears.
        tree.toggle(&a_dir_path);
        tree.rebuild();
        assert!(has_inner(&tree), "expand must add the child node");

        // Collapse → child node removed.
        tree.toggle(&a_dir_path);
        tree.rebuild();
        assert!(!has_inner(&tree), "collapse must remove the child node");

        // The lazy explorer produces no folds.
        assert!(
            tree.compute_folds().is_empty(),
            "lazy explorer has no folds"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn render_text_line_count_matches_nodes() {
        let root = make_tree();
        let tree = ExplorerTree::new(root.clone());
        let text = tree.render_text();
        let line_count = text.lines().count();
        assert_eq!(
            line_count,
            tree.nodes.len(),
            "render_text line count must equal node count"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn render_text_line_count_after_toggle() {
        // Lazy model: render_text always equals nodes.len() (no hidden lines),
        // and expanding a dir adds its children, growing the line count.
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        let line_count_before = tree.render_text().lines().count();
        assert_eq!(line_count_before, tree.nodes.len());

        let a_dir_path = tree.nodes[1].path.clone();
        tree.set_expanded(&a_dir_path, true);
        tree.rebuild();
        let line_count_after = tree.render_text().lines().count();
        assert_eq!(line_count_after, tree.nodes.len());
        assert!(
            line_count_after > line_count_before,
            "expanding a_dir must add inner.txt's line"
        );
        let _ = fs::remove_dir_all(&root);
    }

    /// Whole-tree search integration: a deep file in a collapsed dir is absent
    /// from the lazy tree, but opening it (what selecting it in the fuzzy finder
    /// does) reveals it — expanding its ancestors and selecting its row. This is
    /// the Snacks-style "find file → reveal in tree" path.
    #[test]
    fn opening_deep_file_reveals_it_in_lazy_tree() {
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("a").join("b")).unwrap();
        std::fs::write(tmp.path().join("a").join("b").join("deep.txt"), b"x").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        let has_deep = |app: &super::super::App| {
            app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .unwrap()
                .tree
                .nodes
                .iter()
                .any(|n| n.path.file_name().is_some_and(|f| f == "deep.txt"))
        };
        // Lazy: deep.txt is under collapsed dirs → absent.
        assert!(!has_deep(&app), "deep.txt must be absent before reveal");

        // Open it (the fuzzy finder's select path does exactly this).
        app.dispatch_action(AppAction::FocusRight, 1);
        app.dispatch_ex("edit a/b/deep.txt");

        // The explorer followed the active buffer: deep.txt now in the tree, and
        // its ancestor dirs were lazily expanded to reach it.
        let revealed = has_deep(&app);
        let a_expanded = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .is_expanded(&std::env::current_dir().unwrap().join("a"));
        assert!(revealed, "opening deep.txt must reveal it in the lazy tree");
        assert!(a_expanded, "reveal must lazily expand ancestor dir `a`");
    }

    /// `/` in the explorer opens the whole-tree fuzzy finder (not a plain buffer
    /// search over the visible lazy rows).
    #[test]
    fn slash_in_explorer_opens_fuzzy_finder() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.txt"), b"x").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        assert!(app.explorer_buf_focused(), "explorer must be focused");
        assert!(app.picker.is_none() && app.search_field.is_none());

        // Press `/` through the production keypress path.
        let _ = app.handle_keypress(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));

        let picker_open = app.picker.is_some();
        let search_open = app.search_field.is_some();
        assert!(picker_open, "/ in the explorer must open the fuzzy finder");
        assert!(
            !search_open,
            "/ in the explorer must NOT open the plain buffer search prompt"
        );
    }

    #[test]
    fn toggle_explorer_creates_window_and_is_explorer_slot() {
        use crate::keymap_actions::AppAction;
        let mut app = super::super::App::new(None, false, None, None).unwrap();
        assert!(app.tabs[app.active_tab].explorer.is_none());
        // Open explorer.
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        assert!(
            app.tabs[app.active_tab].explorer.is_some(),
            "explorer should be open"
        );
        assert!(
            app.slots.iter().any(|s| s.is_explorer()),
            "explorer slot must have is_explorer = true"
        );
        // Close explorer.
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        assert!(
            app.tabs[app.active_tab].explorer.is_none(),
            "explorer should be closed"
        );
    }

    #[test]
    fn explorer_reconcile_creates_file_on_disk() {
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("existing.txt"), "hi").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let idx = app.explorer_slot_idx().expect("explorer slot");
        // Append a new file line at the root depth (name_col = 2).
        let cur = app.slots[idx].buffer().as_string();
        let newtext = format!("{cur}\n  newfile.rs");
        BufferEdit::replace_all(app.slots[idx].buffer_mut(), &newtext);
        // Explorer is in Normal mode by default; run the reconcile.
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        let created = tmp.path().join("newfile.rs");
        let exists = created.exists();
        assert!(exists, "reconcile must create newfile.rs on disk");
    }

    #[test]
    fn explorer_o_type_esc_creates_file_real_flow() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("existing.txt"), "hi").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        // Replicate the event loop: handle each key; on FallThrough, route to
        // the insert dispatcher (insert mode) or the engine (otherwise); then
        // run the post-key reconcile hook exactly as run() does.
        use crate::app::event_loop::KeyOutcome;
        use hjkl_engine::VimMode;
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        press(&mut app, KeyCode::Char('o'));
        for c in "made.rs".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        let created = tmp.path().join("made.rs");
        let exists = created.exists();
        // The list must reflect disk: the created file appears in the buffer.
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();
        assert!(exists, "o + type + Esc must create the file on disk");
        assert!(
            buf.contains("made.rs"),
            "created file must appear in the explorer list; buf=<<<{buf}>>>"
        );
    }

    /// Creating a multi-level item (`somedir/test.txt`) must auto-expand the new
    /// directory so the freshly-created leaf is visible in the lazy tree.
    #[test]
    fn create_multilevel_item_expands_new_dir() {
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("existing.txt"), "hi").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let idx = app.explorer_slot_idx().unwrap();

        // Append a root-level line for a nested create (2-space indent = depth 1).
        let cur = app.slots[idx].buffer().as_string();
        let newtext = format!("{cur}\n  somedir/test.txt");
        BufferEdit::replace_all(app.slots[idx].buffer_mut(), &newtext);
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        let cwd = std::env::current_dir().unwrap();
        let dir_exists = cwd.join("somedir").is_dir();
        let file_exists = cwd.join("somedir").join("test.txt").exists();
        let ep = app.tabs[app.active_tab].explorer.as_ref().unwrap();
        let expanded = ep.tree.is_expanded(&cwd.join("somedir"));
        let test_visible = ep
            .tree
            .nodes
            .iter()
            .any(|n| n.path.file_name().is_some_and(|f| f == "test.txt"));

        assert!(dir_exists, "somedir/ must be created on disk");
        assert!(file_exists, "somedir/test.txt must be created on disk");
        assert!(
            expanded,
            "the new dir must be expanded after creating a child in it"
        );
        assert!(
            test_visible,
            "test.txt must be visible in the tree after the multi-level create"
        );
    }

    #[test]
    fn explorer_dd_trashes_file_and_drops_it_from_list() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("victim.txt"), "bye").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        // Move onto the victim.txt line (line 1, below the root) and delete it.
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let on_disk = tmp.path().join("victim.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();
        assert!(!on_disk, "dd must remove victim.txt from disk (to trash)");
        assert!(
            !buf.contains("victim.txt"),
            "dd'd file must drop from the list; buf=<<<{buf}>>>"
        );
    }

    /// Renaming a file in the explorer must retarget any open buffer on that
    /// file: the slot's filename follows the rename, and a subsequent `:w`
    /// writes the NEW path instead of silently recreating the old one
    /// (forking the file).
    #[test]
    fn explorer_rename_retargets_open_buffer() {
        use crate::app::explorer_reconcile::ID_SEP;
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.txt"), "alpha\n").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_ex("edit a.txt");
        let file_slot = app
            .slots
            .iter()
            .position(|s| s.filename.as_deref().is_some_and(|p| p.ends_with("a.txt")))
            .expect("a.txt must be open in a slot");

        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let idx = app.explorer_slot_idx().unwrap();
        let cur = app.slots[idx].buffer().as_string();
        let renamed = cur.replace(&format!("a.txt{ID_SEP}"), &format!("b.txt{ID_SEP}"));
        assert_ne!(cur, renamed, "buffer must contain the a.txt line");
        BufferEdit::replace_all(app.slots[idx].buffer_mut(), &renamed);
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        // Disk: renamed.
        assert!(!tmp.path().join("a.txt").exists(), "a.txt renamed away");
        assert_eq!(
            std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
            "alpha\n"
        );
        // Open buffer must follow the rename.
        let fname = app.slots[file_slot].filename.clone().expect("filename");
        assert!(
            fname.ends_with("b.txt"),
            "open buffer must be retargeted to the new path, got {fname:?}"
        );
        // `:w` from the file window writes the NEW path; the old path must
        // NOT be recreated (that would fork the file).
        app.dispatch_action(AppAction::FocusRight, 1);
        assert_eq!(app.focused_slot_idx(), file_slot, "file window focused");
        app.dispatch_ex("w");
        assert!(
            !tmp.path().join("a.txt").exists(),
            ":w must not recreate the pre-rename path"
        );
        assert_eq!(
            std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
            "alpha\n"
        );
    }

    /// Renaming a DIRECTORY in the explorer must rebase open buffers on files
    /// nested under it (path-component prefix, not string prefix).
    #[test]
    fn explorer_dir_rename_rebases_nested_open_buffer() {
        use crate::app::explorer_reconcile::ID_SEP;
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("d")).unwrap();
        std::fs::write(tmp.path().join("d").join("inner.txt"), "nested").unwrap();
        // A sibling whose name merely string-starts with "d" must NOT be
        // rebased by the dir rename.
        std::fs::write(tmp.path().join("dx.txt"), "decoy").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_ex("edit d/inner.txt");
        app.dispatch_ex("edit dx.txt");
        let inner_slot = app
            .slots
            .iter()
            .position(|s| {
                s.filename
                    .as_deref()
                    .is_some_and(|p| p.ends_with("d/inner.txt"))
            })
            .expect("d/inner.txt open");
        let decoy_slot = app
            .slots
            .iter()
            .position(|s| s.filename.as_deref().is_some_and(|p| p.ends_with("dx.txt")))
            .expect("dx.txt open");

        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let idx = app.explorer_slot_idx().unwrap();
        let cur = app.slots[idx].buffer().as_string();
        let renamed = cur.replace(&format!("d/{ID_SEP}"), &format!("e/{ID_SEP}"));
        assert_ne!(cur, renamed, "buffer must contain the d/ dir line");
        BufferEdit::replace_all(app.slots[idx].buffer_mut(), &renamed);
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        assert!(
            tmp.path().join("e").join("inner.txt").exists(),
            "dir renamed on disk"
        );
        let fname = app.slots[inner_slot].filename.clone().expect("filename");
        assert!(
            fname.ends_with("e/inner.txt"),
            "nested open buffer must be rebased under the renamed dir, got {fname:?}"
        );
        let decoy = app.slots[decoy_slot].filename.clone().expect("filename");
        assert!(
            decoy.ends_with("dx.txt") && !decoy.ends_with("e/dx.txt"),
            "sibling with a string-prefix name must NOT be rebased, got {decoy:?}"
        );
    }

    /// Trashing a file that is open in a buffer must keep the buffer's
    /// content and flag it deleted-on-disk (same as the fs-watch delete
    /// path) — the reconcile itself must not silently write the file back.
    #[test]
    fn explorer_trash_flags_open_buffer_deleted_keeps_content() {
        use crate::keymap_actions::AppAction;
        use hjkl_engine::BufferEdit;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.txt"), "alpha").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_ex("edit a.txt");
        let file_slot = app
            .slots
            .iter()
            .position(|s| s.filename.as_deref().is_some_and(|p| p.ends_with("a.txt")))
            .expect("a.txt open");

        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let idx = app.explorer_slot_idx().unwrap();
        let cur = app.slots[idx].buffer().as_string();
        let without: String = cur
            .lines()
            .filter(|l| !l.contains("a.txt"))
            .collect::<Vec<_>>()
            .join("\n");
        assert_ne!(cur, without, "buffer must contain the a.txt line");
        BufferEdit::replace_all(app.slots[idx].buffer_mut(), &without);
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        assert!(
            !tmp.path().join("a.txt").exists(),
            "a.txt trashed on disk — the reconcile must not write it back"
        );
        // Buffer content survives, filename is kept, slot flagged deleted.
        assert_eq!(
            app.slots[file_slot].buffer().as_string(),
            "alpha",
            "the open buffer's content must survive the trash"
        );
        assert!(
            app.slots[file_slot]
                .filename
                .as_deref()
                .is_some_and(|p| p.ends_with("a.txt")),
            "filename kept so an explicit :w can bring the file back"
        );
        assert_eq!(
            app.slots[file_slot].disk_state,
            super::super::DiskState::DeletedOnDisk,
            "trashed open buffer must be flagged deleted-on-disk"
        );
    }

    #[test]
    fn create_keeps_focus_in_explorer() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("existing.txt"), "hi").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        press(&mut app, KeyCode::Char('o'));
        for c in "newone.rs".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        let focused_explorer = app.explorer_buf_focused();
        let on_disk = tmp.path().join("newone.rs").exists();
        assert!(on_disk, "file should be created");
        assert!(
            focused_explorer,
            "focus must stay in the explorer after creating a file"
        );
    }

    #[test]
    fn paste_moves_cursor_to_pasted_file() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
        std::fs::write(tmp.path().join("z.txt"), "z").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        // The trash lives in its own directory outside the explored tree, so
        // trashed entries never appear as rows (the walk covers the whole tree).
        let _trash = isolate_trash(&mut app);
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        // Cursor onto a.txt (line 1), delete it (trash), then paste it back.
        press(&mut app, KeyCode::Char('j')); // onto a.txt
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d')); // a.txt trashed
        press(&mut app, KeyCode::Char('p')); // paste -> restore a.txt

        let on_disk = tmp.path().join("a.txt").exists();
        let cursor_path = {
            let ep = app.tabs[app.active_tab].explorer.as_ref().unwrap();
            let row = app.window_cursor(ep.win_id).0;
            ep.tree.nodes.get(row).map(|n| n.path.clone())
        };
        assert!(on_disk, "a.txt should be restored on disk");
        assert_eq!(
            cursor_path.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())),
            Some("a.txt".to_string()),
            "cursor should land on the pasted file"
        );
    }

    #[test]
    fn o_on_dir_creates_child_inside() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("sub")).unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        // Cursor onto the `sub/` dir (line 1), then `o` + name + Esc.
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('o'));
        for c in "kid.txt".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        let inside = tmp.path().join("sub").join("kid.txt").exists();
        let sibling = tmp.path().join("kid.txt").exists();
        assert!(
            inside,
            "o on a dir must create the file INSIDE it (sub/kid.txt)"
        );
        assert!(!sibling, "must NOT create a sibling at root (kid.txt)");
    }

    #[test]
    fn cap_o_on_dir_creates_sibling_not_deeper_neighbor() {
        // Real bug repro: `O` on a sibling dir whose *preceding buffer row* is
        // a DEEPER child of the previous dir must copy the cursor line's indent
        // (sibling level), NOT the deeper neighbour's.
        //   <root>
        //     aaa/
        //       kid.txt   <- deeper row, physically above zzz/
        //     zzz/        <- cursor here, press O
        // Expect `twin.txt` at root, NOT inside aaa/.
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("aaa")).unwrap();
        std::fs::write(tmp.path().join("aaa").join("kid.txt"), b"").unwrap();
        std::fs::create_dir(tmp.path().join("zzz")).unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        // root(0) → aaa/(1), expand it (reveals kid.txt), → kid.txt(2) → zzz/(3).
        press(&mut app, KeyCode::Char('j')); // onto aaa/
        press(&mut app, KeyCode::Enter); // expand aaa/
        press(&mut app, KeyCode::Char('j')); // onto kid.txt
        press(&mut app, KeyCode::Char('j')); // onto zzz/
        press(&mut app, KeyCode::Char('O'));
        for c in "twin.txt".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        let sibling = tmp.path().join("twin.txt").exists();
        let inside = tmp.path().join("aaa").join("twin.txt").exists();
        assert!(
            sibling,
            "O on zzz/ must create a sibling at root (twin.txt)"
        );
        assert!(
            !inside,
            "must NOT inherit the deeper neighbour's indent (aaa/twin.txt)"
        );
    }

    #[test]
    fn buffer_line_click_maps_past_interleaved_explorer_slot() {
        use crate::app::mouse::{Zone, buffer_line_x_ranges, hit_test_zone};
        use crate::keymap_actions::AppAction;
        let pid = std::process::id();
        let f0 = std::env::temp_dir().join(format!("hjkl_bl_a_{pid}.txt"));
        let f1 = std::env::temp_dir().join(format!("hjkl_bl_b_{pid}.txt"));
        std::fs::write(&f0, "a").unwrap();
        std::fs::write(&f1, "b").unwrap();
        let mut app = super::super::App::new(Some(f0.clone()), false, None, None).unwrap();
        // Open the explorer so its slot lands BETWEEN the two file slots.
        app.dispatch_action(AppAction::ToggleExplorer, 1); // slot 1 = explorer
        app.dispatch_action(AppAction::FocusRight, 1); // focus the editor window
        app.dispatch_ex(&format!("edit {}", f1.display())); // slot 2 = f1

        // View line shows f0, f1 (explorer skipped). Clicking the LAST entry
        // must resolve to f1's real slot — NOT the interleaved explorer slot.
        let ranges = buffer_line_x_ranges(&app, 80);
        assert!(ranges.len() >= 2, "expected >=2 entries, got {ranges:?}");
        let col = ranges[ranges.len() - 1].0;
        match hit_test_zone(&app, col, 0) {
            Zone::BufferLine { slot_idx } => {
                assert!(
                    !app.slots[slot_idx].is_explorer(),
                    "buffer-line click must not map to the explorer slot"
                );
                assert_eq!(
                    app.slots[slot_idx].filename.as_deref(),
                    Some(f1.as_path()),
                    "last buffer-line entry must map to f1"
                );
            }
            other => panic!("expected BufferLine zone, got {other:?}"),
        }
        let _ = std::fs::remove_file(&f0);
        let _ = std::fs::remove_file(&f1);
    }

    #[test]
    fn switch_to_never_targets_the_explorer_window() {
        use crate::keymap_actions::AppAction;
        let f1 = std::env::temp_dir().join(format!("hjkl_exp_st_{}.txt", std::process::id()));
        std::fs::write(&f1, "hello").unwrap();
        let mut app = super::super::App::new(Some(f1.clone()), false, None, None).unwrap();
        // Open the explorer — it gets focused.
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        assert!(app.explorer_buf_focused(), "explorer should be focused");

        // Switching to the real buffer while the explorer is focused must NOT
        // clobber the explorer pane — it redirects to a non-explorer window.
        let real_idx = app.slots.iter().position(|s| !s.is_explorer()).unwrap();
        app.switch_to(real_idx);

        assert!(
            !app.explorer_buf_focused(),
            "switch_to must move focus off the explorer window"
        );
        assert!(
            !app.active().is_explorer(),
            "active buffer must be the real one"
        );
        let ep_win = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        let ep_slot = app.windows[ep_win].as_ref().unwrap().slot;
        assert!(
            app.slots[ep_slot].is_explorer(),
            "the explorer window must still display the explorer buffer"
        );
        let _ = std::fs::remove_file(&f1);
    }

    #[test]
    fn buffer_next_skips_explorer_slot() {
        use crate::keymap_actions::AppAction;
        // Create two real files so buffer_next has something to cycle through.
        let f1 = std::env::temp_dir().join(format!("hjkl_exp_bn_a_{}.txt", std::process::id()));
        let f2 = std::env::temp_dir().join(format!("hjkl_exp_bn_b_{}.txt", std::process::id()));
        std::fs::write(&f1, "hello").unwrap();
        std::fs::write(&f2, "world").unwrap();
        let mut app = super::super::App::new(Some(f1.clone()), false, None, None).unwrap();
        // Open a second real buffer.
        app.dispatch_ex(&format!("edit {}", f2.display()));
        // Open the explorer (it gets focused).
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        assert!(app.tabs[app.active_tab].explorer.is_some());
        // Focus the right (editor) window so buffer_next operates on a real slot.
        app.dispatch_action(AppAction::FocusRight, 1);
        assert!(!app.active().is_explorer(), "should be on a real slot now");
        // buffer_next should never land on the explorer slot.
        for _ in 0..10 {
            app.buffer_next();
            assert!(
                !app.active().is_explorer(),
                "buffer_next must skip is_explorer slots"
            );
        }
        let _ = std::fs::remove_file(&f1);
        let _ = std::fs::remove_file(&f2);
    }

    // ── New tests for plan features ────────────────────────────────────────

    /// Build a unique temp dir with dotfiles:
    /// root/{ .hidden_dir/, .hidden_file, visible.txt }
    fn make_dotfile_tree() -> PathBuf {
        let base =
            std::env::temp_dir().join(format!("hjkl_explorer_dot_test_{}", std::process::id()));
        let _ = fs::remove_dir_all(&base);
        fs::create_dir_all(base.join(".hidden_dir")).unwrap();
        fs::write(base.join(".hidden_file"), "x").unwrap();
        fs::write(base.join("visible.txt"), "x").unwrap();
        base
    }

    #[test]
    fn read_children_shows_dotfiles_by_default() {
        let root = make_dotfile_tree();
        let tree = ExplorerTree::new(root.clone());
        // Dotfiles are shown by default now; `.git` would still be skipped.
        let names = child_names(&tree);
        assert!(
            names.iter().any(|n| n.starts_with('.')),
            "dotfiles should be shown by default, got: {names:?}"
        );
        assert!(
            names.contains(&"visible.txt".to_string()),
            "visible.txt should be present"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn toggle_hidden_hides_then_shows_dotfiles() {
        let root = make_dotfile_tree();
        let mut tree = ExplorerTree::new(root.clone());
        assert!(
            child_names(&tree).iter().any(|n| n.starts_with('.')),
            "dotfiles should be shown before toggle"
        );
        tree.toggle_hidden();
        let names = child_names(&tree);
        assert!(
            !names.iter().any(|n| n.starts_with('.')),
            "dotfiles should be hidden after toggle_hidden, got: {names:?}"
        );
        // Toggle back.
        tree.toggle_hidden();
        assert!(
            child_names(&tree).iter().any(|n| n.starts_with('.')),
            "dotfiles should be shown again after second toggle"
        );
        let _ = fs::remove_dir_all(&root);
    }

    /// The unification claim, made checkable: the explorer's tree and the
    /// walk the file picker and both grep paths run must select the same set
    /// of paths. Anything visible here is findable there and vice versa.
    ///
    /// Compared against `hjkl_fs::project::walk_builder` rather than against a
    /// hand-written expected list, so the two cannot drift apart without this
    /// failing — a hardcoded list would just be a second policy to keep in
    /// step.
    #[test]
    fn explorer_tree_lists_exactly_what_the_shared_walk_selects() {
        use std::collections::BTreeSet;
        let base = std::env::temp_dir().join(format!(
            "hjkl_explorer_policy_{}_{}",
            std::process::id(),
            line!()
        ));
        let _ = fs::remove_dir_all(&base);
        // `.gitignore` rules only apply inside a repository — the marker
        // directory is what arms them, and it must also stay out of the tree.
        fs::create_dir_all(base.join(".git")).unwrap();
        fs::write(base.join(".git").join("config"), "x").unwrap();
        fs::write(base.join(".gitignore"), "target/\nsecret.txt\n").unwrap();
        fs::create_dir_all(base.join("target")).unwrap();
        fs::write(base.join("target").join("build.o"), "x").unwrap();
        fs::create_dir_all(base.join(".github")).unwrap();
        fs::write(base.join(".github").join("ci.yml"), "x").unwrap();
        fs::write(base.join(".env.example"), "x").unwrap();
        fs::write(base.join("secret.txt"), "x").unwrap();
        fs::write(base.join("main.rs"), "x").unwrap();

        let mut tree = ExplorerTree::new(base.clone());
        // Expand everything so the lazy tree covers the same depth as the walk.
        for p in [base.join(".github"), base.join("target")] {
            tree.expanded.insert(p);
        }
        tree.rebuild();

        let from_tree: BTreeSet<String> = tree
            .nodes
            .iter()
            .filter(|n| n.path != base)
            .map(|n| {
                n.path
                    .strip_prefix(&base)
                    .unwrap_or(&n.path)
                    .to_string_lossy()
                    .into_owned()
            })
            .collect();
        let from_walk: BTreeSet<String> = hjkl_fs::project::walk_builder(&base)
            .build()
            .filter_map(Result::ok)
            .filter(|e| e.depth() > 0)
            .map(|e| {
                e.path()
                    .strip_prefix(&base)
                    .unwrap_or_else(|_| e.path())
                    .to_string_lossy()
                    .into_owned()
            })
            .collect();

        assert_eq!(from_tree, from_walk);
        // Spot-check the properties the equality is standing in for, so a
        // future change that breaks BOTH sides the same way still fails.
        assert!(from_tree.contains(".env.example"), "{from_tree:?}");
        assert!(!from_tree.contains("secret.txt"), "{from_tree:?}");
        assert!(
            !from_tree.iter().any(|p| p.starts_with("target")),
            "{from_tree:?}"
        );
        assert!(
            !from_tree
                .iter()
                .any(|p| p.starts_with(".git/") || p == ".git"),
            "{from_tree:?}"
        );
        let _ = fs::remove_dir_all(&base);
    }

    #[test]
    fn reveal_expands_ancestors_and_returns_row() {
        let root = make_tree();
        // Build a fresh tree (root expanded by default, a_dir is NOT expanded).
        let mut tree = ExplorerTree::new(root.clone());
        let inner = root.join("a_dir").join("inner.txt");
        // inner.txt is two levels deep; reveal should expand a_dir.
        let row = tree.reveal(&inner);
        assert!(
            row.is_some(),
            "reveal should return a row for an existing path"
        );
        let row = row.unwrap();
        assert_eq!(
            tree.nodes[row].path, inner,
            "node at returned row should be inner.txt"
        );
        assert!(
            tree.is_expanded(&root.join("a_dir")),
            "a_dir should be expanded after reveal"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn set_root_re_roots_to_parent() {
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.join("a_dir"));
        // a_dir is the root; set_root to root's parent brings us up.
        tree.set_root(root.clone());
        // Now root should be the root and a_dir visible as a child.
        assert_eq!(tree.root, root);
        let names = child_names(&tree);
        assert!(
            names.contains(&"a_dir".to_string()),
            "a_dir should be a visible child after set_root: {names:?}"
        );
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn explorer_slot_features_disabled_normal_slot_features_enabled() {
        use crate::keymap_actions::AppAction;

        // Open the app with an unnamed scratch buffer (slot 0 = normal).
        let mut app = super::super::App::new(None, false, None, None).unwrap();

        // Normal slot must have all features on.
        let normal = &app.slots[0];
        assert!(
            normal.features.syntax,
            "normal slot: syntax should be enabled"
        );
        assert!(normal.features.lsp, "normal slot: lsp should be enabled");
        assert!(
            normal.features.hover,
            "normal slot: hover should be enabled"
        );

        // Open the explorer — its slot is pushed after the normal slot.
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let explorer_idx = app
            .slots
            .iter()
            .position(|s| s.is_explorer())
            .expect("explorer slot must exist after ToggleExplorer");
        let exp = &app.slots[explorer_idx];

        assert!(
            !exp.features.syntax,
            "explorer slot: syntax should be disabled"
        );
        assert!(!exp.features.lsp, "explorer slot: lsp should be disabled");
        assert!(
            !exp.features.hover,
            "explorer slot: hover should be disabled"
        );
    }

    /// Explorer slot must be modifiable (oil.nvim-style editing).
    #[test]
    fn explorer_slot_is_modifiable() {
        use crate::keymap_actions::AppAction;

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let explorer_idx = app
            .slots
            .iter()
            .position(|s| s.is_explorer())
            .expect("explorer slot must exist after ToggleExplorer");
        let exp = &app.slots[explorer_idx];
        assert!(
            exp.is_modifiable(),
            "explorer slot must be modifiable (oil.nvim-style editing)"
        );
    }

    /// `render_text` emits `name/` for non-root directory nodes.
    #[test]
    fn render_text_dirs_have_trailing_slash() {
        let root = make_tree();
        let tree = ExplorerTree::new(root.clone());
        let text = tree.render_text();
        let lines: Vec<&str> = text.lines().collect();
        // The root line (depth 0) has no trailing slash.
        let root_name = root
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        assert!(
            lines[0].trim_start().starts_with(&root_name) && !lines[0].trim_start().ends_with('/'),
            "root line must not end with '/': {:?}",
            lines[0]
        );
        // Non-root dir nodes (a_dir, b_dir) must contain trailing '/' before
        // the ID_SEP tail. Strip the id tail first.
        for line in &lines[1..] {
            let trimmed = line.trim_start();
            // Strip id tail (everything from first ID_SEP onward).
            let name_part = if let Some(sep) = trimmed.find(crate::app::explorer_reconcile::ID_SEP)
            {
                &trimmed[..sep]
            } else {
                trimmed
            };
            if name_part.starts_with("a_dir") || name_part.starts_with("b_dir") {
                assert!(
                    name_part.ends_with('/'),
                    "dir line must end with '/' (before id tail): {trimmed:?}"
                );
            }
        }
        let _ = fs::remove_dir_all(&root);
    }

    /// `<C-s>` → ExplorerOpenVsplit; `<C-S-s>` → ExplorerOpenSplit; `<C-v>` is
    /// UNBOUND (falls through to visual-block); `<C-t>` → ExplorerOpenTab;
    /// `gh` → ExplorerToggleHidden; `gi` → ExplorerToggleGitignore.
    #[test]
    fn explorer_keymap_ctrl_open_and_g_toggles() {
        use crate::app::keymap_build::build_explorer_keymap;
        use crate::keymap_actions::AppAction;
        use hjkl_keymap::{KeyEvent as KmKeyEvent, KeyResolve};
        use hjkl_vim::Mode;

        let now = std::time::Instant::now();

        // Feed a sequence of key events into a fresh keymap and return the
        // matched action, if any. A fresh keymap is built per call because
        // Keymap doesn't implement Clone.
        let resolve = |events: &[KmKeyEvent]| -> Option<AppAction> {
            let mut km = build_explorer_keymap(' ');
            let mut result = None;
            for &ev in events {
                match km.feed(Mode::Normal, ev, now) {
                    KeyResolve::Match(b) => {
                        result = Some(b.action);
                        break;
                    }
                    KeyResolve::Pending => {}
                    KeyResolve::Ambiguous => {
                        if let KeyResolve::Match(b) = km.timeout_resolve(Mode::Normal) {
                            result = Some(b.action);
                        }
                        break;
                    }
                    KeyResolve::Unbound(_) => break,
                }
            }
            result
        };

        assert_eq!(
            resolve(&[KmKeyEvent::ctrl('s')]),
            Some(AppAction::ExplorerOpenVsplit),
            "<C-s> must map to ExplorerOpenVsplit"
        );
        assert_eq!(
            resolve(&[KmKeyEvent::new(
                hjkl_keymap::KeyCode::Char('s'),
                hjkl_keymap::KeyModifiers::CTRL | hjkl_keymap::KeyModifiers::SHIFT,
            )]),
            Some(AppAction::ExplorerOpenSplit),
            "<C-S-s> must map to ExplorerOpenSplit"
        );
        assert_eq!(
            resolve(&[KmKeyEvent::ctrl('v')]),
            None,
            "<C-v> must be unbound in the explorer (falls through to visual-block)"
        );
        assert_eq!(
            resolve(&[KmKeyEvent::ctrl('t')]),
            Some(AppAction::ExplorerOpenTab),
            "<C-t> must map to ExplorerOpenTab"
        );
        assert_eq!(
            resolve(&[KmKeyEvent::char('g'), KmKeyEvent::char('h')]),
            Some(AppAction::ExplorerToggleHidden),
            "gh must map to ExplorerToggleHidden"
        );
        assert_eq!(
            resolve(&[KmKeyEvent::char('g'), KmKeyEvent::char('i')]),
            Some(AppAction::ExplorerToggleGitignore),
            "gi must map to ExplorerToggleGitignore"
        );
    }

    /// `dd` on an OPEN (unfolded) dir deletes the WHOLE subtree — the dir and
    /// its children are trashed together; children must NOT be orphaned to root.
    #[test]
    fn dd_open_dir_deletes_subtree_no_orphans() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("d")).unwrap();
        std::fs::write(tmp.path().join("d").join("a.rs"), b"a").unwrap();
        std::fs::write(tmp.path().join("d").join("b.rs"), b"b").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());
        // The explorer roots at the canonicalized cwd; build expected paths from
        // it (tempdirs are symlinked on macOS/CI, so raw `tmp.path()` mismatches).
        let base = std::env::current_dir().unwrap();

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let _win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        let dir = base.join("d");
        let dir_row = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == dir)
            .unwrap();
        app.set_explorer_window_cursor(dir_row, 0, None);
        // OPEN (unfold) the dir via Enter (ExplorerActivate), matching the real
        // keypress path rather than calling the helper directly.
        press(&mut app, KeyCode::Enter);
        // dd on the now-open dir.
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let dir_gone = !base.join("d").exists();
        let a_orphan = base.join("a.rs").exists();
        let b_orphan = base.join("b.rs").exists();
        assert!(dir_gone, "the dir must be deleted (trashed)");
        assert!(
            !a_orphan && !b_orphan,
            "children must NOT be orphaned to root (a={a_orphan} b={b_orphan})"
        );
    }

    /// `dd` a dir, then `p` on another dir → the dir moves INTO the target dir
    /// with its contents preserved (full keypress → reconcile → filesystem).
    #[test]
    fn dd_dir_then_p_on_dir_moves_into_target() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("mover")).unwrap();
        std::fs::write(tmp.path().join("mover").join("inner.txt"), b"CONTENT").unwrap();
        std::fs::create_dir(tmp.path().join("target")).unwrap();
        std::fs::write(tmp.path().join("target").join("keep.txt"), b"k").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());
        let base = std::env::current_dir().unwrap();

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let _win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        let row_of = |app: &super::super::App, p: &std::path::Path| -> Option<usize> {
            app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .unwrap()
                .tree
                .nodes
                .iter()
                .position(|n| n.path == p)
        };
        let set_cursor = |app: &mut super::super::App, row: usize| {
            app.set_explorer_window_cursor(row, 0, None);
        };

        let mover = base.join("mover");
        let mr = row_of(&app, &mover).unwrap();
        set_cursor(&mut app, mr);
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let target = base.join("target");
        let tr = row_of(&app, &target).expect("target row after dd");
        set_cursor(&mut app, tr);
        press(&mut app, KeyCode::Char('p'));

        let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
        let buf = app.slots[slot].buffer().as_string();
        let inside = base.join("target").join("mover").join("inner.txt");
        let at_root = base.join("mover").join("inner.txt");
        let inside_exists = inside.exists();
        let inside_content = std::fs::read(&inside).ok();
        let root_exists = at_root.exists();
        assert!(
            inside_exists && inside_content.as_deref() == Some(b"CONTENT".as_slice()),
            "expected target/mover/inner.txt with CONTENT.\n inside={inside_exists} content={inside_content:?} root_still={root_exists}\n buffer:\n{buf:?}"
        );
    }

    // ── nodes_from_buffer round-trip tests ─────────────────────────────────

    /// `render_text` → `nodes_from_buffer` must reproduce the same
    /// path/depth/is_dir/is_last/branches for a non-trivial tree.
    #[test]
    fn nodes_from_buffer_roundtrip_simple() {
        let root = make_tree();
        // Full-tree walk: depth-2 nodes always present; toggle only changes folds.
        let tree = ExplorerTree::new(root.clone());

        let text = tree.render_text();
        let parsed = nodes_from_buffer(&text, &root);

        assert_eq!(
            tree.nodes.len(),
            parsed.len(),
            "roundtrip must preserve node count; tree.nodes={} parsed={}",
            tree.nodes.len(),
            parsed.len()
        );

        for (i, (orig, got)) in tree.nodes.iter().zip(parsed.iter()).enumerate() {
            assert_eq!(orig.path, got.path, "node[{i}] path mismatch");
            assert_eq!(orig.depth, got.depth, "node[{i}] depth mismatch");
            assert_eq!(orig.is_dir, got.is_dir, "node[{i}] is_dir mismatch");
            assert_eq!(orig.is_last, got.is_last, "node[{i}] is_last mismatch");
            assert_eq!(orig.branches, got.branches, "node[{i}] branches mismatch");
        }

        let _ = fs::remove_dir_all(&root);
    }

    /// Round-trip for a deeper nested tree with mixed last/non-last siblings.
    #[test]
    fn nodes_from_buffer_roundtrip_nested_mixed() {
        // Build: root/{ p/ { a/ { x.rs }, b.rs }, q.rs }
        let base = std::env::temp_dir().join(format!("hjkl_nfb_nested_{}", std::process::id()));
        let _ = fs::remove_dir_all(&base);
        fs::create_dir_all(base.join("p").join("a")).unwrap();
        fs::write(base.join("p").join("a").join("x.rs"), "").unwrap();
        fs::write(base.join("p").join("b.rs"), "").unwrap();
        fs::write(base.join("q.rs"), "").unwrap();

        // Full-tree walk: all nodes present without needing to toggle.
        let tree = ExplorerTree::new(base.clone());

        let text = tree.render_text();
        let parsed = nodes_from_buffer(&text, &base);

        assert_eq!(tree.nodes.len(), parsed.len(), "node count mismatch");
        for (i, (orig, got)) in tree.nodes.iter().zip(parsed.iter()).enumerate() {
            assert_eq!(orig.path, got.path, "node[{i}] path");
            assert_eq!(orig.depth, got.depth, "node[{i}] depth");
            assert_eq!(orig.is_dir, got.is_dir, "node[{i}] is_dir");
            assert_eq!(orig.is_last, got.is_last, "node[{i}] is_last");
            assert_eq!(orig.branches, got.branches, "node[{i}] branches");
        }

        let _ = fs::remove_dir_all(&base);
    }

    /// Mid-edit alignment: a blank line (e.g. a fresh `o`/`O` open-line before
    /// the name is typed) must become a `None` slot so every real node below it
    /// keeps its buffer-row index — otherwise the render overlay paints icons /
    /// git colors a row off.
    #[test]
    fn overlay_nodes_blank_line_keeps_alignment() {
        let root = Path::new("/r");
        //  row0: root           depth 0
        //  row1: dir/           depth 1
        //  row2: <blank>        (fresh open-line — no name yet)
        //  row3: file.txt       depth 1
        let text = "  r\n    dir/\n      \n    file.txt";
        let ov = overlay_nodes_from_buffer(text, root);

        assert_eq!(ov.len(), 4, "one slot per buffer line");
        assert_eq!(ov[0].as_ref().unwrap().depth, 0, "root depth 0");
        assert!(ov[1].as_ref().unwrap().is_dir, "row1 is the dir");
        assert_eq!(ov[1].as_ref().unwrap().depth, 1);
        assert!(ov[2].is_none(), "blank line → None slot (alignment hole)");
        let f = ov[3].as_ref().expect("row3 present");
        assert!(!f.is_dir, "row3 is the file");
        assert_eq!(f.depth, 1, "file stays at depth 1 below the blank");
        assert_eq!(
            f.path,
            Path::new("/r/file.txt"),
            "path resolved to root child"
        );
    }

    /// Opening a buffer reveals it in the tree WITHOUT collapsing other
    /// expanded dirs, and moves the selection onto the opened file. Regression
    /// for the destructive full-rebuild that snapped search/click-opened folds
    /// shut and landed the cursorline on the wrong row.
    #[test]
    fn reveal_active_keeps_other_folds_open_and_selects_file() {
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("aaa")).unwrap();
        std::fs::write(tmp.path().join("aaa").join("akid.txt"), b"").unwrap();
        std::fs::create_dir(tmp.path().join("bbb")).unwrap();
        std::fs::write(tmp.path().join("bbb").join("target.txt"), b"x").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        // Open `aaa/`'s fold directly in the buffer WITHOUT touching `expanded`
        // — this mimics a search reveal (`/`+`n`), the exact case the old
        // rebuild-from-`expanded` path wrongly collapsed on the next buffer
        // open.
        let aaa = std::env::current_dir().unwrap().join("aaa");
        let win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        let aaa_row = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == aaa)
            .unwrap();
        let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
        app.slots[slot].buffer_mut().open_fold_at(aaa_row);
        let aaa_open = !app.slots[slot]
            .buffer()
            .folds()
            .iter()
            .any(|f| f.start_row == aaa_row && f.closed);
        assert!(aaa_open, "precondition: aaa fold open after activate");

        // Focus the editor window (not the explorer) before opening, so `:edit`
        // doesn't replace the explorer buffer — mirrors the real file-open path.
        app.dispatch_action(AppAction::FocusRight, 1);
        // Open a file under `bbb/` → triggers explorer_reveal_active.
        app.dispatch_ex("edit bbb/target.txt");

        let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
        let aaa_row2 = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == aaa)
            .unwrap();
        let aaa_still_open = !app.slots[slot]
            .buffer()
            .folds()
            .iter()
            .any(|f| f.start_row == aaa_row2 && f.closed);

        let target = std::env::current_dir()
            .unwrap()
            .join("bbb")
            .join("target.txt");
        let target_row = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == target)
            .unwrap();
        let cur = app.window_cursor(win_id).0;

        assert!(
            aaa_still_open,
            "opening a file must not collapse other expanded dirs"
        );
        assert_eq!(
            cur, target_row,
            "explorer selection must move to the opened file"
        );
    }

    /// Opening a VISIBLE file must not scroll the explorer. With the lazy walk a
    /// collapsed dir's children aren't in the buffer, so the file below it sits
    /// near the top — reveal must leave `top_row` alone.
    #[test]
    fn reveal_active_does_not_scroll_when_file_visible() {
        use crate::app::window::LayoutRect;
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        // A collapsed dir with 30 children sits above a root-level file. The
        // children are NOT loaded (lazy), so target.txt is at a low row.
        std::fs::create_dir(tmp.path().join("aaa")).unwrap();
        for i in 0..30 {
            std::fs::write(tmp.path().join("aaa").join(format!("f{i:02}.txt")), b"").unwrap();
        }
        std::fs::write(tmp.path().join("target.txt"), b"x").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        let win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        if let Some(Some(w)) = app.windows.get_mut(win_id) {
            w.last_rect = Some(LayoutRect::new(0, 0, 30, 24));
        }
        let target = std::env::current_dir().unwrap().join("target.txt");
        let target_row = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == target)
            .unwrap();
        assert!(
            target_row < 24,
            "lazy: aaa's children unloaded, so target's row ({target_row}) is on screen"
        );

        // Open target.txt from the editor window → triggers reveal_active.
        app.dispatch_action(AppAction::FocusRight, 1);
        app.dispatch_ex("edit target.txt");

        let top = app.window_scroll(win_id).0;
        assert_eq!(
            top, 0,
            "explorer must not scroll when the opened file is already visible"
        );
    }

    /// Activating an EMPTY directory must be a no-op — it has no fold of its
    /// own, so it must NOT toggle (and collapse) the enclosing parent fold.
    #[test]
    fn activate_empty_dir_does_not_collapse_parent() {
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(tmp.path().join("parent").join("empty")).unwrap();
        std::fs::write(tmp.path().join("parent").join("file.txt"), b"").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;

        let row_of = |app: &super::super::App, p: &std::path::Path| -> usize {
            app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .unwrap()
                .tree
                .nodes
                .iter()
                .position(|n| n.path == p)
                .unwrap()
        };
        let fold_open_at = |app: &super::super::App, row: usize| -> bool {
            let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
            !app.slots[slot]
                .buffer()
                .folds()
                .iter()
                .any(|f| f.start_row == row && f.closed)
        };
        let activate = |app: &mut super::super::App, row: usize| {
            app.set_explorer_window_cursor(row, 0, None);
            app.explorer_activate();
        };

        let parent = std::env::current_dir().unwrap().join("parent");
        let empty = parent.join("empty");

        // Expand `parent/` so its children (incl. the empty dir) are visible.
        let pr = row_of(&app, &parent);
        activate(&mut app, pr);
        let parent_row = row_of(&app, &parent);
        assert!(
            fold_open_at(&app, parent_row),
            "parent fold open after expand"
        );

        // Activate the EMPTY dir — must be a no-op, parent stays open.
        let er = row_of(&app, &empty);
        activate(&mut app, er);
        let parent_row2 = row_of(&app, &parent);
        assert!(
            fold_open_at(&app, parent_row2),
            "activating an empty dir must not collapse the parent fold"
        );
    }

    /// `:debug` toggles the global `debug_mode` flag; `:debug on` / `:debug off`
    /// set it explicitly.
    #[test]
    fn debug_ex_command_toggles_debug_mode() {
        let mut app = super::super::App::new(None, false, None, None).unwrap();
        assert!(!app.debug_mode, "default off");
        app.dispatch_ex("debug");
        assert!(app.debug_mode, ":debug toggles on");
        app.dispatch_ex("debug");
        assert!(!app.debug_mode, ":debug toggles back off");
        app.dispatch_ex("debug on");
        assert!(app.debug_mode, ":debug on forces on");
        app.dispatch_ex("debug on");
        assert!(app.debug_mode, ":debug on is idempotent");
        app.dispatch_ex("debug off");
        assert!(!app.debug_mode, ":debug off forces off");
    }

    /// `o<Esc>` (open-line, no name typed) must not leave a stray blank line in
    /// the tree — the no-op reconcile has to normalize the buffer back to the
    /// canonical render.
    #[test]
    fn o_then_esc_leaves_no_blank_line() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("a.txt"), b"").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let press = |app: &mut super::super::App, code: KeyCode| {
            let key = KeyEvent::new(code, KeyModifiers::NONE);
            let consumed = matches!(
                app.handle_keypress(key),
                KeyOutcome::Continue | KeyOutcome::Break
            );
            if !consumed {
                if app.active_editor().vim_mode() == VimMode::Insert {
                    app.dispatch_insert_key(key);
                } else {
                    hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
                }
            }
            app.reconcile_window_editors();
            app.maybe_reconcile_explorer();
        };
        press(&mut app, KeyCode::Char('o'));
        press(&mut app, KeyCode::Esc);

        let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
        let text = app.slots[slot].buffer().as_string();
        assert!(
            !text.split('\n').any(|l| l.trim().is_empty()),
            "o<Esc> must not leave a blank line; buffer was {text:?}"
        );
    }

    /// A fold toggle while the explorer is UNFOCUSED must keep its per-window
    /// `window_folds` snapshot in lockstep with the buffer — otherwise the
    /// unfocused-render path (which reads the snapshot) and the glyph overlay
    /// (which reads the buffer) disagree and the tree renders garbled.
    #[test]
    fn explorer_fold_toggle_keeps_window_folds_in_sync() {
        use crate::keymap_actions::AppAction;
        let tmp = tempfile::tempdir().unwrap();
        std::fs::create_dir(tmp.path().join("sub")).unwrap();
        std::fs::write(tmp.path().join("sub").join("k.txt"), b"").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        // Focus away → populates window_folds[explorer] (the snapshot the
        // unfocused render path uses).
        app.dispatch_action(AppAction::FocusRight, 1);
        assert!(
            app.window_folds.contains_key(&win_id),
            "precondition: per-window fold snapshot present"
        );

        // Toggle `sub/`'s fold via the activate path (what a mouse click runs).
        let sub = std::env::current_dir().unwrap().join("sub");
        let sub_row = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .position(|n| n.path == sub)
            .unwrap();
        app.set_explorer_window_cursor(sub_row, 0, None);
        app.explorer_activate();

        let slot = app.slots.iter().position(|s| s.is_explorer()).unwrap();
        let buf_folds = app.slots[slot].buffer().folds();
        let snap = app.window_folds.get(&win_id).cloned().unwrap();
        assert_eq!(
            snap, buf_folds,
            "window_folds snapshot must match buffer folds after a fold toggle"
        );
    }

    // ── Shared helpers ─────────────────────────────────────────────────────

    /// Point the open explorer pane's trash at a fresh `TempDir` and return it.
    /// Bind it for the whole test — dropping it deletes the trash.
    ///
    /// Replaces `cwd.set_env("XDG_CACHE_HOME", …)` in every explorer test that
    /// deletes something. The env override reached the entire process: any
    /// concurrently-running test that constructed an `App` resolved
    /// `$XDG_CACHE_HOME/hjkl/swap` and so created an `hjkl/` directory inside
    /// whatever the overriding test had pointed the variable at. Where that was
    /// the test's own explorer root, `hjkl/` appeared as a tree row, sorted
    /// above the file (directories first), and `j` + `dd` deleted it instead —
    /// leaving the victim on disk and failing "dd must trash". That is the
    /// whole of the `ddu_restores_file` / `ddu_then_redo_retrashes` /
    /// `dd_u_then_ctrl_r_retrashes` flake, reproduced deterministically by
    /// pre-creating `<root>/hjkl/swap`.
    ///
    /// The root here is a directory of its own, outside the explored tree, so
    /// nothing the trash creates can ever be a row; and because it is named
    /// rather than resolved, no other test can reach it and it cannot reach
    /// any other test.
    #[must_use]
    fn isolate_trash(app: &mut super::super::App) -> tempfile::TempDir {
        let td = tempfile::tempdir().unwrap();
        app.tabs[app.active_tab]
            .explorer
            .as_mut()
            .expect("the explorer pane must be open before isolating its trash")
            .trash_root = hjkl_app::trash::TrashRoot::At(td.path().join("trash"));
        td
    }

    /// Returns `true` when any DIRECT CHILD (depth 1) of the explorer root has
    /// the given filename. Use this instead of `buf.contains(name)` to avoid
    /// false positives from nested paths (e.g. trash/ subdir containing the name).
    fn has_root_child(app: &super::super::App, name: &str) -> bool {
        app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .is_some_and(|ep| {
                ep.tree
                    .nodes
                    .iter()
                    .any(|n| n.depth == 1 && n.path.file_name().is_some_and(|f| f == name))
            })
    }

    /// Press a key through the full event loop path (same as existing tests).
    fn press(app: &mut super::super::App, code: crossterm::event::KeyCode) {
        use crate::app::event_loop::KeyOutcome;
        use crossterm::event::{KeyEvent, KeyModifiers};
        use hjkl_engine::VimMode;
        let key = KeyEvent::new(code, KeyModifiers::NONE);
        let consumed = matches!(
            app.handle_keypress(key),
            KeyOutcome::Continue | KeyOutcome::Break
        );
        if !consumed {
            if app.active_editor().vim_mode() == VimMode::Insert {
                app.dispatch_insert_key(key);
            } else {
                hjkl_vim_tui::handle_key(app.active_editor_mut(), key);
            }
        }
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();
    }

    // ── Undo / redo filesystem op tests ────────────────────────────────────

    /// `dd` trashes a file; `u` undoes the buffer edit → reconcile restores
    /// the file from trash and the line reappears in the explorer.
    #[test]
    fn ddu_restores_file() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("victim.txt"), "content").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        // Private trash, so the restore cannot pick up a stale entry from a
        // parallel test — and no `XDG_CACHE_HOME` override, so no parallel test
        // can create anything inside this test's explorer root.
        let _trash = isolate_trash(&mut app);

        // j → move onto victim.txt; dd → delete the line.
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let on_disk_after_dd = tmp.path().join("victim.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf_after_dd = app.slots[idx].buffer().as_string();
        let root_child_after_dd = has_root_child(&app, "victim.txt");

        // u → undo the buffer deletion; reconcile should restore the file.
        press(&mut app, KeyCode::Char('u'));

        let on_disk_after_u = tmp.path().join("victim.txt").exists();
        let buf_after_u = app.slots[idx].buffer().as_string();
        let root_child_after_u = has_root_child(&app, "victim.txt");

        assert!(!on_disk_after_dd, "dd must trash victim.txt");
        assert!(
            !root_child_after_dd,
            "dd must drop victim.txt from root-level nodes; buf=<<<{buf_after_dd}>>>"
        );
        assert!(
            on_disk_after_u,
            "u must restore victim.txt from trash; buf_after_u=<<<{buf_after_u}>>>"
        );
        assert!(
            root_child_after_u,
            "u must restore victim.txt to root-level nodes; buf=<<<{buf_after_u}>>>"
        );
    }

    /// `dd` puts the entry in the pane's own `trash_root` and nowhere else.
    ///
    /// Every other test in this family would pass identically if the injected
    /// root were ignored and `TrashRoot::Xdg` used instead — they only assert
    /// that the victim left its original path, which is true either way. This
    /// one reads the destination, so it fails if the injection is a no-op, and
    /// it is what makes the rest of the family's isolation a fact rather than
    /// an assumption.
    #[test]
    fn dd_trashes_into_the_injected_root_only() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("victim.txt"), "content").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let trash = isolate_trash(&mut app);
        let root = trash.path().join("trash");

        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        assert!(
            !tmp.path().join("victim.txt").exists(),
            "dd must trash victim.txt"
        );

        let entries: Vec<String> = std::fs::read_dir(&root)
            .unwrap_or_else(|e| panic!("injected trash root {root:?} must exist: {e}"))
            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
            .collect();
        assert!(
            entries.iter().any(|n| n.starts_with("victim.txt.")),
            "the trashed entry must be in the injected root {root:?}, found {entries:?}"
        );
        assert!(
            !tmp.path().join("hjkl").exists(),
            "the trash must not create anything inside the explorer root"
        );
    }

    /// `o` + name + `<Esc>` creates a file; `u` removes it again.
    #[test]
    fn create_then_undo_removes_file() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        // o → insert line below; type "fresh.rs"; Esc → reconcile.
        press(&mut app, KeyCode::Char('o'));
        for c in "fresh.rs".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        let on_disk_after_create = tmp.path().join("fresh.rs").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf_after_create = app.slots[idx].buffer().as_string();
        let root_child_after_create = has_root_child(&app, "fresh.rs");

        // Creating a new file opens it in the main window (focus moves there).
        // Refocus the explorer so `u` operates on the explorer buffer.
        let ep_win = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        app.set_focused_window(ep_win);
        app.sync_viewport_to_editor();

        // u → undo the insert; reconcile should trash fresh.rs.
        press(&mut app, KeyCode::Char('u'));

        let on_disk_after_u = tmp.path().join("fresh.rs").exists();
        let buf_after_u = app.slots[idx].buffer().as_string();
        let root_child_after_u = has_root_child(&app, "fresh.rs");

        assert!(
            on_disk_after_create,
            "o+type+Esc must create fresh.rs on disk"
        );
        assert!(
            root_child_after_create,
            "created file must appear in root nodes; buf=<<<{buf_after_create}>>>"
        );
        assert!(
            !on_disk_after_u,
            "u must remove fresh.rs from disk (trash it); buf_after_u=<<<{buf_after_u}>>>"
        );
        assert!(
            !root_child_after_u,
            "u must drop fresh.rs from root nodes; buf=<<<{buf_after_u}>>>"
        );
    }

    // ── Git-aware reconcile tests ───────────────────────────────────────────

    /// `dd` on a git-tracked file: the file is gone from disk but stays in the
    /// explorer list as a red (Deleted) node because it was tracked by git.
    ///
    /// Windows-gated: git2's `workdir` canonicalizes the temp path (UNC `\\?\`)
    /// while the explorer keys off `current_dir()`, so the status-map join never
    /// matches on Windows — a CI-only path-form mismatch, not a logic bug.
    #[cfg(not(target_os = "windows"))]
    #[test]
    fn git_repo_dd_tracked_stays_red() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        // Create an isolated git repo with one committed file.
        let tmp = tempfile::tempdir().unwrap();

        // Init a git repo, create + commit a tracked file.
        let run = |args: &[&str], dir: &std::path::Path| {
            std::process::Command::new("git")
                .args(args)
                .current_dir(dir)
                .output()
                .expect("git command failed")
        };
        run(&["init"], tmp.path());
        run(&["config", "user.email", "t@t.com"], tmp.path());
        run(&["config", "user.name", "T"], tmp.path());
        std::fs::write(tmp.path().join("tracked.txt"), "tracked").unwrap();
        run(&["add", "tracked.txt"], tmp.path());
        run(&["commit", "-m", "init"], tmp.path());

        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        // Trash outside the explored tree — see the note in `non_git_dd_vanishes`.
        let _trash = isolate_trash(&mut app);

        // Navigate to tracked.txt and delete it (dd).
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let on_disk = tmp.path().join("tracked.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();

        // Check that the node has Deleted git status.
        let node_git = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .and_then(|ep| {
                ep.tree
                    .nodes
                    .iter()
                    .find(|n| n.path.file_name().is_some_and(|f| f == "tracked.txt"))
            })
            .map(|n| n.git);

        assert!(!on_disk, "dd must remove tracked.txt from disk (to trash)");
        assert!(
            buf.contains("tracked.txt"),
            "tracked deleted file must remain in explorer list; buf=<<<{buf}>>>"
        );
        assert!(
            matches!(node_git, Some(Some(hjkl_app::git::ExplorerGit::Deleted))),
            "deleted tracked file node must have git status Deleted; got {node_git:?}"
        );
    }

    /// `dd` on a file in a non-git directory: file is gone AND gone from list.
    #[test]
    fn non_git_dd_vanishes() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        // Temp dir that is NOT a git repo.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("untracked.txt"), "bye").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        // The trash must live *outside* the explored tree: a trash root inside
        // it puts the trash's own directory in the tree, and since directories
        // sort first the `j` below would land on it instead of the file under
        // test. `isolate_trash` gives it a `TempDir` of its own.
        let _trash = isolate_trash(&mut app);

        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));

        let on_disk = tmp.path().join("untracked.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();

        assert!(!on_disk, "dd must remove untracked.txt from disk");
        assert!(
            !has_root_child(&app, "untracked.txt"),
            "non-git dd'd file must vanish from list; buf=<<<{buf}>>>"
        );
    }

    /// `dd` → `u` (journal undo) → file back on disk + listed normally.
    /// After undo: undo_stack empty, redo_stack has one entry.
    #[test]
    fn dd_then_u_restores() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("recover.txt"), "restore_me").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        // dd
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));
        assert!(
            !tmp.path().join("recover.txt").exists(),
            "must be trashed after dd"
        );
        // undo_stack has 1 entry after dd.
        let undo_len = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .map_or(0, |ep| ep.undo_stack.len());
        assert_eq!(undo_len, 1, "undo_stack must have 1 entry after dd");

        // u → journal undo
        press(&mut app, KeyCode::Char('u'));
        let on_disk = tmp.path().join("recover.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();
        let undo_len2 = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .map_or(1, |ep| ep.undo_stack.len());
        let redo_len = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .map_or(0, |ep| ep.redo_stack.len());

        assert!(on_disk, "u must restore recover.txt to disk");
        assert!(
            buf.contains("recover.txt"),
            "u must show recover.txt in list; buf=<<<{buf}>>>"
        );
        assert_eq!(undo_len2, 0, "undo_stack must be empty after u");
        assert_eq!(redo_len, 1, "redo_stack must have 1 entry after u");
    }

    /// `dd` → `u` → `<C-r>` → file trashed again.
    #[test]
    fn dd_u_then_ctrl_r_retrashes() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("trashable.txt"), "yo").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));
        assert!(!tmp.path().join("trashable.txt").exists(), "dd must trash");

        press(&mut app, KeyCode::Char('u'));
        assert!(tmp.path().join("trashable.txt").exists(), "u must restore");

        // <C-r> redo
        let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
        let consumed = matches!(
            app.handle_keypress(ctrl_r),
            KeyOutcome::Continue | KeyOutcome::Break
        );
        if !consumed {
            hjkl_vim_tui::handle_key(app.active_editor_mut(), ctrl_r);
        }
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        let on_disk = tmp.path().join("trashable.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();

        assert!(!on_disk, "<C-r> must re-trash trashable.txt");
        assert!(
            !has_root_child(&app, "trashable.txt"),
            "<C-r> must drop trashable.txt from root nodes; buf=<<<{buf}>>>"
        );
    }

    /// `o` + name + `<Esc>` creates; `u` trashes the new file.
    #[test]
    fn create_then_u_removes() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        press(&mut app, KeyCode::Char('o'));
        for c in "newborn.rs".chars() {
            press(&mut app, KeyCode::Char(c));
        }
        press(&mut app, KeyCode::Esc);

        assert!(
            tmp.path().join("newborn.rs").exists(),
            "o+type+Esc must create the file"
        );

        // Refocus explorer after file creation opened it in the editor window.
        let ep_win = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;
        app.set_focused_window(ep_win);
        app.sync_viewport_to_editor();

        press(&mut app, KeyCode::Char('u'));

        let on_disk = tmp.path().join("newborn.rs").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();

        assert!(!on_disk, "u must remove newborn.rs (trash it)");
        assert!(
            !has_root_child(&app, "newborn.rs"),
            "u must drop newborn.rs from root nodes; buf=<<<{buf}>>>"
        );
    }

    /// `dd` → `u` (restored) → `<C-r>` re-trashes the file.
    #[test]
    fn ddu_then_redo_retrashes() {
        use crate::app::event_loop::KeyOutcome;
        use crate::keymap_actions::AppAction;
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(tmp.path().join("victim.txt"), "data").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);

        // j, dd → trash.
        press(&mut app, KeyCode::Char('j'));
        press(&mut app, KeyCode::Char('d'));
        press(&mut app, KeyCode::Char('d'));
        assert!(!tmp.path().join("victim.txt").exists(), "dd must trash");

        // u → restore.
        press(&mut app, KeyCode::Char('u'));
        assert!(tmp.path().join("victim.txt").exists(), "u must restore");

        // <C-r> → redo → re-trash.
        // Ctrl+r is handled by hjkl_vim_tui::handle_key (not handle_keypress).
        let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
        let consumed = matches!(
            app.handle_keypress(ctrl_r),
            KeyOutcome::Continue | KeyOutcome::Break
        );
        if !consumed {
            hjkl_vim_tui::handle_key(app.active_editor_mut(), ctrl_r);
        }
        app.reconcile_window_editors();
        app.maybe_reconcile_explorer();

        let on_disk = tmp.path().join("victim.txt").exists();
        let idx = app.explorer_slot_idx().unwrap();
        let buf = app.slots[idx].buffer().as_string();

        assert!(!on_disk, "<C-r> must re-trash victim.txt");
        assert!(
            !has_root_child(&app, "victim.txt"),
            "<C-r> must remove victim.txt from root nodes; buf=<<<{buf}>>>"
        );
    }

    // ── Lazy-walk tests ─────────────────────────────────────────────────────────

    /// Lazy walk: a collapsed dir's children are NOT in `nodes`; expanding adds
    /// them, collapsing removes them.
    #[test]
    fn lazy_collapsed_dir_children_absent() {
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        let a_dir_path = tree.nodes[1].path.clone();
        assert!(!tree.is_expanded(&a_dir_path), "a_dir must start collapsed");
        let has_inner = |t: &ExplorerTree| {
            t.nodes
                .iter()
                .any(|n| n.path.file_name().is_some_and(|f| f == "inner.txt"))
        };
        assert!(
            !has_inner(&tree),
            "inner.txt must be absent while a_dir is collapsed (lazy walk)"
        );
        tree.set_expanded(&a_dir_path, true);
        tree.rebuild();
        assert!(
            has_inner(&tree),
            "inner.txt must appear once a_dir is expanded"
        );
        let _ = fs::remove_dir_all(&root);
    }

    /// The lazy explorer emits no folds (the node list is the visible tree).
    #[test]
    fn compute_folds_is_empty() {
        let root = make_tree();
        let mut tree = ExplorerTree::new(root.clone());
        assert!(tree.compute_folds().is_empty(), "no folds when collapsed");
        let a_dir_path = tree.nodes[1].path.clone();
        tree.set_expanded(&a_dir_path, true);
        tree.rebuild();
        assert!(
            tree.compute_folds().is_empty(),
            "no folds when expanded either"
        );
        let _ = fs::remove_dir_all(&root);
    }

    /// Scrolling the explorer must not corrupt the sidebar render (#1.12 user
    /// report): after `e` motions advance the viewport `top_row`, every frame's
    /// visible rows must still paint the correct buffer row, tree connector
    /// glyph (`├`/`└`) and cursor block. Draws after EVERY press — the real TUI
    /// redraws per keystroke, so a scroll-dependent stale offset (cached
    /// overlay/conceal data read at pre-scroll rows) would garble a frame here.
    #[test]
    fn scrolling_keeps_explorer_rows_aligned() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;
        use ratatui::style::Modifier;
        use ratatui::{Terminal, backend::TestBackend};

        // A tree taller than the 23-row pane so `e` motions force a scroll.
        let tmp = tempfile::tempdir().unwrap();
        for d in ["aaa", "bbb", "ccc", "ddd", "eee", "fff"] {
            std::fs::create_dir_all(tmp.path().join(d)).unwrap();
            for i in 0..20 {
                std::fs::write(tmp.path().join(d).join(format!("{d}_{i:02}.rs")), "x").unwrap();
            }
        }
        for i in 0..40 {
            std::fs::write(tmp.path().join(format!("f_{i:02}.txt")), "x").unwrap();
        }
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(Some("f_00.txt".into()), false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;

        // Expand every top-level dir through the app's own activate path so the
        // reconcile baseline stays in sync (mutating the tree directly leaves
        // the baseline stale and `maybe_reconcile_explorer` snaps the cursor
        // back after every press).
        loop {
            let row = app.window_cursor(win_id).0;
            let is_dir = app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .unwrap()
                .tree
                .nodes
                .get(row)
                .is_some_and(|n| n.is_dir);
            if !is_dir {
                break;
            }
            app.explorer_activate();
            let next = app.window_cursor(win_id).0;
            if next == row {
                break; // activate collapsed instead of expanding — bail out
            }
        }

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        // Two frames settle window rects/viewports and populate the
        // `ExplorerRenderCache` for the pre-scroll `dirty_gen`.
        terminal
            .draw(|f| crate::render::frame(f, &mut app))
            .unwrap();
        terminal
            .draw(|f| crate::render::frame(f, &mut app))
            .unwrap();

        let area = app.windows[win_id].as_ref().unwrap().last_rect.unwrap();
        let (top, height) = (area.y, area.h);
        assert!(
            (20..=25).contains(&height),
            "pane must be tall enough to scroll through; got {height}"
        );

        // Extract the name as the LAST contiguous run of non-space symbols in
        // the row (glyphs come first: guide/connector/icon, then the name). A
        // stale conceal would leave the `<US><id>` tail visible and extend the
        // run, so this also pins the conceal rows.
        let rendered_name = |buf: &ratatui::buffer::Buffer, y: u16| -> String {
            let mut runs: Vec<String> = Vec::new();
            let mut cur = String::new();
            for x in 0..area.w {
                let sym = buf.cell((x, top + y)).map_or(" ", |c| c.symbol());
                if sym == " " || sym.is_empty() {
                    if !cur.is_empty() {
                        runs.push(std::mem::take(&mut cur));
                    }
                } else {
                    cur.push_str(sym);
                }
            }
            if !cur.is_empty() {
                runs.push(cur);
            }
            runs.last().cloned().unwrap_or_default()
        };
        let buf_text = app.window_editors[&win_id].buffer().as_string();
        let expected_name = |doc_row: usize| -> String {
            let line = buf_text.split('\n').nth(doc_row).unwrap_or("");
            let line = line.trim_start_matches(' ');
            let end = line.find('\u{1F}').unwrap_or(line.len());
            line[..end].to_string()
        };

        let mut presses = 0;
        let mut scrolled = false;
        for press_i in 0..400 {
            press(&mut app, KeyCode::Char('e'));
            presses += 1;
            let editor_top = app.window_editors[&win_id].host().viewport().top_row;
            if editor_top > 0 {
                scrolled = true;
            }
            terminal
                .draw(|f| crate::render::frame(f, &mut app))
                .unwrap();
            let buf = terminal.backend().buffer().clone();
            let editor_top = app.window_editors[&win_id].host().viewport().top_row;
            let buf_lines: Vec<&str> = buf_text.split('\n').collect();

            // Cursor block: the single REVERSED cell must sit on the cursor's
            // screen row (`cursor_row - top_row`) — not a pre-scroll offset.
            let cur_row = app.window_editors[&win_id].buffer().cursor().row;
            if let Some(expected_scr) = cur_row.checked_sub(editor_top)
                && expected_scr < height as usize
            {
                let rev: Vec<(u16, u16)> = {
                    let mut v = Vec::new();
                    for x in 0..area.w {
                        for y in 0..height {
                            if let Some(c) = buf.cell((x, top + y))
                                && c.modifier.contains(Modifier::REVERSED)
                            {
                                v.push((x, top + y));
                            }
                        }
                    }
                    v
                };
                assert_eq!(
                    rev.len(),
                    1,
                    "exactly one cursor block cell after {press_i} presses \
                     (top_row={editor_top}, cursor_row={cur_row})"
                );
                assert_eq!(
                    rev[0].1,
                    top + expected_scr as u16,
                    "cursor block must sit on the cursor row after {press_i} presses \
                     (top_row={editor_top}, cursor_row={cur_row})"
                );
            }

            for y in 0..height {
                let doc_row = editor_top + y as usize;
                let got = rendered_name(&buf, y);
                let want = expected_name(doc_row);
                assert_eq!(
                    got, want,
                    "row {y} shows the wrong buffer row after {press_i} presses                      (top_row={editor_top}, cursor_row={cur_row})"
                );
                // Connector glyph: `└` on the tree's last-child rows, `├` else.
                // Painted at text_x + branches.len()*2 (text_x = 1 here).
                if doc_row > 0 && doc_row < buf_lines.len() {
                    let (is_last, branches) = app.tabs[app.active_tab]
                        .explorer
                        .as_ref()
                        .unwrap()
                        .tree
                        .nodes
                        .get(doc_row)
                        .map_or((false, 0), |n| (n.is_last, n.branches.len() as u16));
                    let conn_col = 1 + branches * 2;
                    let conn = buf.cell((conn_col, top + y)).map_or("", |c| c.symbol());
                    assert_eq!(
                        conn,
                        if is_last { "└" } else { "├" },
                        "connector glyph on the wrong row after {press_i} presses                          (top_row={editor_top}, doc {doc_row})"
                    );
                }
            }
            if editor_top >= 20 {
                break;
            }
        }
        assert!(
            scrolled && presses < 400,
            "the test must scroll the explorer and finish (presses={presses})"
        );
    }

    /// `explorer_activate` on a dir expands it (adds its children to the tree)
    /// and collapses it again on a second activation (removes them).
    #[test]
    fn activate_dir_expands_and_collapses_tree() {
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;

        let tmp = tempfile::tempdir().unwrap();
        fs::create_dir_all(tmp.path().join("subdir")).unwrap();
        fs::write(tmp.path().join("subdir").join("child.txt"), "x").unwrap();
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(None, false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);

        let nodes_before = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .len();

        // Move to subdir (row 1) and activate → expands, child.txt joins the tree.
        press(&mut app, KeyCode::Char('j'));
        app.explorer_activate();

        let nodes_open = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .len();
        assert!(
            nodes_open > nodes_before,
            "activate on a collapsed dir must add its children"
        );
        let has_child = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .iter()
            .any(|n| n.path.file_name().is_some_and(|f| f == "child.txt"));
        assert!(
            has_child,
            "child.txt must be in the tree after expanding subdir"
        );

        // Activate again → collapses, child.txt drops out.
        app.explorer_activate();
        let nodes_closed = app.tabs[app.active_tab]
            .explorer
            .as_ref()
            .unwrap()
            .tree
            .nodes
            .len();
        assert_eq!(
            nodes_closed, nodes_before,
            "activate again must collapse back to the prior node count"
        );
    }

    /// MID-ANIMATION regression (#1.12): the explorer's animated scroll frame
    /// must render exactly like a static frame at the same viewport top.
    ///
    /// The smooth-scroll design (render.rs `target_top_row`/`vp_top`, dbc142c3)
    /// makes the animated top the single source of truth for text, overlays and
    /// cursor during a scroll anim. A mid-flight frame at animated top A must
    /// therefore be byte-identical (symbols AND styles) to a frame drawn with
    /// the engine top set to A and the animation cleared — otherwise text,
    /// glyph overlay or cursor block disagree by `|target - A|` rows and the
    /// sidebar garbles while scrolling, which is the user's report.
    #[test]
    fn scrolling_animated_frame_keeps_explorer_rows_aligned() {
        use crate::app::ScrollAnim;
        use crate::keymap_actions::AppAction;
        use crossterm::event::KeyCode;
        use ratatui::style::Modifier;
        use ratatui::{Terminal, backend::TestBackend};
        use std::time::{Duration, Instant};

        let tmp = tempfile::tempdir().unwrap();
        for d in ["aaa", "bbb", "ccc", "ddd", "eee", "fff"] {
            std::fs::create_dir_all(tmp.path().join(d)).unwrap();
            for i in 0..20 {
                std::fs::write(tmp.path().join(d).join(format!("{d}_{i:02}.rs")), "x").unwrap();
            }
        }
        for i in 0..40 {
            std::fs::write(tmp.path().join(format!("f_{i:02}.txt")), "x").unwrap();
        }
        let _cwd = crate::test_cwd::CwdGuard::enter(tmp.path());

        let mut app = super::super::App::new(Some("f_00.txt".into()), false, None, None).unwrap();
        app.dispatch_action(AppAction::ToggleExplorer, 1);
        let _trash = isolate_trash(&mut app);
        let win_id = app.tabs[app.active_tab].explorer.as_ref().unwrap().win_id;

        // Expand every top-level dir through the app's own activate path (same
        // rationale as `scrolling_keeps_explorer_rows_aligned`).
        loop {
            let row = app.window_cursor(win_id).0;
            let is_dir = app.tabs[app.active_tab]
                .explorer
                .as_ref()
                .unwrap()
                .tree
                .nodes
                .get(row)
                .is_some_and(|n| n.is_dir);
            if !is_dir {
                break;
            }
            app.explorer_activate();
            let next = app.window_cursor(win_id).0;
            if next == row {
                break; // activate collapsed instead of expanding — bail out
            }
        }

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| crate::render::frame(f, &mut app))
            .unwrap();
        terminal
            .draw(|f| crate::render::frame(f, &mut app))
            .unwrap();

        let area = app.windows[win_id].as_ref().unwrap().last_rect.unwrap();
        let (top, height) = (area.y, area.h);
        assert!(
            (20..=25).contains(&height),
            "pane must be tall enough to scroll through; got {height}"
        );

        // Press `e` until the viewport scrolls. On the press that changes the
        // top, arm a LONG-duration animation from the pre-press top toward the
        // new top, started ~1.1s into a 10s anim (ease-out ≈ 0.3 < 0.5) so the
        // animated render top stays at the pre-press value while the engine's
        // (target) top is one row further — the frame where the old render
        // paths disagreed by the animated offset.
        let mut prev_top = 0usize;
        let mut presses = 0;
        let mut checked = false;
        for _ in 0..400 {
            press(&mut app, KeyCode::Char('e'));
            presses += 1;
            let new_top = app.window_editors[&win_id].host().viewport().top_row;
            if new_top == prev_top || checked {
                prev_top = new_top;
                continue;
            }
            checked = true;
            assert_eq!(
                new_top,
                prev_top + 1,
                "each `e` scroll must advance the top by exactly one row                  (prev={prev_top}, new={new_top})"
            );

            // ── Static frame at the animated top (anim cleared, engine top
            //    rewound to prev) ──
            app.scroll_anim = None;
            if let Some(e) = app.window_editors.get_mut(&win_id) {
                e.host_mut().viewport_mut().top_row = prev_top;
            }
            terminal
                .draw(|f| crate::render::frame(f, &mut app))
                .unwrap();
            let buf_static = terminal.backend().buffer().clone();

            // ── Animated frame: engine top at the target, anim mid-flight with
            //    animated top == prev (the misaligned case for the old code) ──
            if let Some(e) = app.window_editors.get_mut(&win_id) {
                e.host_mut().viewport_mut().top_row = new_top;
            }
            app.scroll_anim = Some(ScrollAnim {
                win_id,
                start_top: prev_top,
                target_top: new_top,
                started_at: Instant::now() - Duration::from_millis(1120),
                duration: Duration::from_secs(10),
            });
            let animated_top = app
                .scroll_anim_render_top(win_id)
                .expect("mid-flight anim must yield an animated render top");
            assert_eq!(
                animated_top, prev_top,
                "test setup: the eased frame must still be at the pre-press top                  (got {animated_top}, want {prev_top})"
            );
            terminal
                .draw(|f| crate::render::frame(f, &mut app))
                .unwrap();
            let buf_anim = terminal.backend().buffer().clone();

            // ── Assertion 1: the animated frame must be byte-identical to the
            //    static frame at the same top — text, glyphs, colors, cursor
            //    line and cursor block all agree. Any row that differs is a
            //    scroll-dependent misalignment (pre-fix: the cursor block and
            //    cursorline sat at the TARGET row while the text showed the
            //    animated rows).
            let mut diffs: Vec<(u16, u16, String, String)> = Vec::new();
            for y in 0..height {
                for x in 0..area.w {
                    let a = buf_anim.cell((area.x + x, top + y));
                    let s = buf_static.cell((area.x + x, top + y));
                    let sa = a.map(|c| c.symbol().to_string()).unwrap_or_default();
                    let ss = s.map(|c| c.symbol().to_string()).unwrap_or_default();
                    let differs = sa != ss || a.map(|c| c.style()) != s.map(|c| c.style());
                    if differs {
                        diffs.push((area.x + x, top + y, ss, sa));
                    }
                }
            }
            assert!(
                diffs.is_empty(),
                "mid-animation frame must equal the static frame at the same                  animated top ({} cells differ, first: {diffs:?}); target top                  {new_top}, animated top {animated_top}, cursor row {}",
                diffs.len(),
                app.window_editors[&win_id].buffer().cursor().row,
            );

            // ── Assertion 2: exactly one cursor block, on the cursor's screen
            //    row at the ANIMATED top (not the target top).
            let cur_row = app.window_editors[&win_id].buffer().cursor().row;
            let rev: Vec<(u16, u16)> = {
                let mut v = Vec::new();
                for x in 0..area.w {
                    for y in 0..height {
                        if let Some(c) = buf_anim.cell((area.x + x, top + y))
                            && c.modifier.contains(Modifier::REVERSED)
                        {
                            v.push((area.x + x, top + y));
                        }
                    }
                }
                v
            };
            let expected_scr = cur_row
                .checked_sub(animated_top)
                .expect("cursor must be below the animated top on this frame");
            assert_eq!(
                rev.len(),
                1,
                "exactly one cursor block cell mid-animation (top_row={animated_top},                  cursor_row={cur_row})"
            );
            assert_eq!(
                rev[0].1,
                top + expected_scr as u16,
                "cursor block must sit on the cursor's row at the ANIMATED top                  (top_row={animated_top}, cursor_row={cur_row}); it sat on the                  target-top row pre-fix"
            );
            break;
        }
        assert!(
            checked && presses < 400,
            "the test must observe a scroll and finish (presses={presses}, checked={checked})"
        );
    }
}
