use hjkl_engine::Query;
use hjkl_engine_tui::EditorRatatuiExt;
use hjkl_ex::ExEffect;
use hjkl_info_popup::InfoPopup;
use std::path::PathBuf;

// Used when handling SubstituteConfirm to compute char-column from byte offset.
use hjkl_buffer::rope_line_str;

use crate::host::TuiHost;
use crate::save::save_file_durable;
use crate::set_tokens::SetLine;

use super::{App, DiskState, ex_host_cmds};

/// Strip trailing `[ \t]` from every line of `slot`'s buffer, returning the
/// new joined content if anything changed (`None` — no allocation, no
/// dirty-gen bump — when no line had trailing whitespace).
///
/// Used by the `trim_trailing_whitespace` pre-save hook in [`App::save_slot`],
/// which installs the result via `set_content_undoable` (a single undoable
/// step; the syntax / LSP pipelines see a clean `ContentReset` signal) — that
/// needs a live `Editor`, hence the split: this half only needs read access
/// to the shared buffer (#151 Stage 2b).
fn trimmed_trailing_whitespace(slot: &super::BufferSlot) -> Option<String> {
    use hjkl_engine::Query;
    let buf = slot.buffer();
    let n = buf.line_count() as usize;
    let mut changed = false;
    let lines: Vec<String> = (0..n)
        .map(|r| {
            let line = buf.line(r as u32);
            let trimmed = line.trim_end_matches([' ', '\t']);
            if trimmed.len() != line.len() {
                changed = true;
                trimmed.to_string()
            } else {
                line
            }
        })
        .collect();
    if !changed {
        return None;
    }
    // Preserve line count — don't collapse trailing blank lines. The per-line
    // trim above already stripped the whitespace; just rejoin and replace.
    Some(lines.join("\n"))
}

/// Supplemental ex-command NAMES for the `:` completion popup (issue #307).
///
/// These commands EXECUTE via bespoke intercepts and are NOT in either ex
/// registry, so command-name completion never surfaces them. We inject them as
/// completion-only candidates (they resolve to `ArgKind::None` — no typed-arg
/// completion here, which is fine).
///
/// KEEP IN SYNC with the two dispatch sites that own these names:
///   - the `:map`-family verbs accepted by `keymap::parse_mode_groups`
///     (executed in `mappings_dispatch.rs::try_handle_runtime_map`), and
///   - the app-intercepted command names matched directly in `dispatch_ex`
///     below (`:b#`, `:debug`) that no registry knows about.
///
/// Names that ARE registered (`:set`, `:write`, `:wall`, `:qall`, `:wqall`, …)
/// are intentionally excluded — completion already lists them. `:set mouse`
/// is an arg-level intercept of the registered `:set` command, not a distinct
/// command name, so it contributes nothing here.
pub fn extra_ex_command_names() -> Vec<String> {
    [
        // ── `:map` family — see keymap::parse_mode_groups ──
        "map",
        "noremap",
        "nm", //
        "nmap",
        "nnoremap",
        "nunmap",
        "nmapclear", //
        "vmap",
        "vnoremap",
        "vunmap",
        "vmapclear", //
        "xmap",
        "xnoremap",
        "xunmap",
        "xmapclear", //
        "imap",
        "inoremap",
        "iunmap",
        "imapclear", //
        "omap",
        "onoremap",
        "ounmap",
        "omapclear", //
        "cmap",
        "cnoremap",
        "cunmap",
        "cmapclear", //
        "tmap",
        "tnoremap",
        "tunmap",
        "tmapclear", //
        "unmap",
        "mapclear", //
        // ── app-intercepted, unregistered — see dispatch_ex match arm ──
        "b#",
        "debug",
    ]
    .iter()
    .map(|s| s.to_string())
    .collect()
}

/// The TUI's [`crate::set_tokens::SetHost`]: `mouse` mutates terminal capture
/// and the per-mode flags, `explorer.open` applies and persists the dock
/// preference, and endofline tokens apply to the focused slot's
/// [`crate::save::EolState`].
///
/// This is the EXACT logic of the inline pre-pass `dispatch_ex` used to run
/// before the interception moved into [`crate::set_tokens`]; the shared fn
/// preserves the order (mouse/explorer in one token pass, endofline on the
/// remainder) so behavior is unchanged. `info` forwards endofline query
/// replies (`:set eol?`) to the notification bus, as the inline block did.
impl crate::set_tokens::SetHost for App {
    fn set_mouse(&mut self, token: &str) -> bool {
        match token {
            "mouse" => {
                self.set_mouse_capture(true);
                self.mouse_flags = crate::app::MouseFlags::all();
                true
            }
            "nomouse" => {
                self.set_mouse_capture(false);
                self.mouse_flags = crate::app::MouseFlags::none();
                true
            }
            "mouse!" => {
                let new_on = !self.mouse_enabled;
                self.set_mouse_capture(new_on);
                self.mouse_flags = if new_on {
                    crate::app::MouseFlags::all()
                } else {
                    crate::app::MouseFlags::none()
                };
                true
            }
            "mouse?" => {
                let flags_str = self.mouse_flags.as_flags_str();
                self.bus.info(if self.mouse_enabled {
                    format!("mouse={flags_str}")
                } else {
                    "nomouse".to_string()
                });
                true
            }
            other if other.starts_with("mouse=") => {
                let flags_str = &other["mouse=".len()..];
                let flags = crate::app::MouseFlags::from_flags(flags_str);
                let any_on =
                    flags.normal || flags.visual || flags.insert || flags.command || flags.help;
                self.mouse_flags = flags;
                self.set_mouse_capture(any_on);
                true
            }
            _ => false,
        }
    }

    fn set_explorer_open(&mut self, token: &str) -> bool {
        match token {
            // The left dock is host state (it lives outside every tab's
            // `LayoutTree`), so there is no engine `Settings` field for
            // hjkl-ex to apply this to. This is the ONLY writer of the key;
            // toggling the dock deliberately leaves it alone (see
            // `dock::App::set_explorer_open`).
            "explorer.open?" => {
                let open = self.config.explorer.open;
                self.bus.info(format!("explorer.open={open}"));
                true
            }
            other if other.starts_with("explorer.open=") => {
                match &other["explorer.open=".len()..] {
                    "true" => self.set_explorer_open(true),
                    "false" => self.set_explorer_open(false),
                    bad => {
                        self.error(format!(
                            "E474: Invalid argument: explorer.open={bad} \
                             (expected true or false)"
                        ));
                    }
                }
                true
            }
            _ => false,
        }
    }

    fn set_endofline<'a>(&mut self, tokens: Vec<&'a str>) -> (Vec<&'a str>, Vec<String>) {
        // `'endofline'` is buffer-local host state derived from the bytes on
        // disk (`BufferSlot::eol`), not an engine `Settings` field, so it is
        // applied to the focused slot's `EolState` here.
        let slot_idx = self.focused_slot_idx();
        let mut eol = self.slots[slot_idx].eol;
        let (rest, replies) = crate::save::take_endofline_tokens(tokens.into_iter(), &mut eol);
        self.slots[slot_idx].eol = eol;
        (rest, replies)
    }

    fn info(&mut self, msg: String) {
        self.bus.info(msg);
    }

    fn error(&mut self, msg: String) {
        self.bus.error(msg);
    }
}

impl App {
    /// Execute an ex command string (without the leading `:`).
    pub(crate) fn dispatch_ex(&mut self, cmd: &str) {
        let raw = cmd.trim();
        if raw.is_empty() {
            return;
        }
        // Capture for `@:` repeat (Phase 5d, kryptic-sh/hjkl#71).
        // Vim captures every executed command including errored ones; match that.
        // The literal user text is stored; replay re-runs through dispatch_ex so
        // behavior is identical.
        self.last_ex_command = Some(raw.to_string());
        // Phase 1 (#37): push to ex history ring.
        let raw_owned = raw.to_string();
        Self::push_history(&mut self.ex_history, &raw_owned);

        // Phase 7: expand `%`, `#`, `<cword>`, `<cWORD>` tokens in the command
        // line BEFORE dispatch so file-argument commands see literal paths.
        //
        // Two vim-parity guards (kryptic-sh/hjkl): never expand the LEADING
        // RANGE — a leading `%` there is "whole buffer" (`:%s/…`), not the
        // current-file token — and never expand the BODY of substitute/global/
        // normal commands, whose `%`/`#` are literal regex/replacement/keystroke
        // characters (`:s/100%/done/`). Without these, `:%s/foo/bar/g` rewrote
        // the leading `%` to the filename and the command failed to dispatch.
        let cmd_expanded = {
            // Strip the leading range (its text is preserved for re-dispatch).
            let after_range = match hjkl_ex::parse_range(raw, self.active_editor()) {
                Ok((_, rest)) => rest,
                Err(_) => raw,
            };
            let range_len = raw.len() - after_range.len();
            // Canonical command name of the body (resolve abbreviations).
            let name: String = after_range
                .chars()
                .take_while(char::is_ascii_alphabetic)
                .collect();
            let canon = {
                let reg = hjkl_ex::default_registry::<TuiHost>();
                reg.resolve(&name)
                    .map(|c| c.name.to_string())
                    .unwrap_or(name)
            };
            if matches!(
                canon.as_str(),
                "substitute" | "global" | "vglobal" | "normal"
            ) {
                raw.to_string()
            } else {
                let ctx = build_expand_context(self);
                let head = &raw[..range_len];
                format!("{head}{}", hjkl_ex::expand_args(&ctx, after_range))
            }
        };
        // Shadow both `raw` and `cmd` so all dispatch arms see the expanded text.
        let raw = cmd_expanded.as_str();
        let cmd = raw;

        if self.try_handle_runtime_map(raw) {
            return;
        }

        // Resolve abbreviations via hjkl-ex registry (replaces legacy
        // ex::canonical_command_name). Splits the first word, resolves it
        // through the registry's prefix-match table, and reconstructs the
        // full command with the canonical name so the local match arms see
        // `"wall"` when the user typed `:wa`, etc.
        let canon_buf: String;
        let cmd: &str = {
            let reg = hjkl_ex::default_registry::<TuiHost>();
            let first_space = cmd.find(' ');
            let first_word = first_space.map_or(cmd, |i| &cmd[..i]);
            if let Some(resolved) = reg.resolve(first_word) {
                if resolved.name != first_word {
                    canon_buf = match first_space {
                        Some(i) => format!("{}{}", resolved.name, &cmd[i..]),
                        None => resolved.name.to_string(),
                    };
                    &canon_buf
                } else {
                    cmd
                }
            } else {
                cmd
            }
        };

        // App-level `:set mouse` / `:set nomouse` / `:set mouse=<flags>` /
        // `:set explorer.open=…` / `:set endofline` — host-owned state, not
        // engine options (hjkl's option registry rejects them), so the host
        // intercepts these tokens here via the shared `set_tokens` pass.
        // [`crate::set_tokens::SetHost`] for `App` applies what each token
        // means in TUI mode; residual tokens (if any) flow through to the
        // engine as a rebuilt `:set ...` line so combined forms like
        // `:set nu nomouse` work.
        let rebuilt: String;
        let intercepted: Option<SetLine> = cmd.strip_prefix("set ").map(|body| {
            let body = body.trim();
            if body.is_empty() {
                SetLine::PassThrough
            } else {
                crate::set_tokens::intercept_set_tokens(body, self)
            }
        });
        let cmd: &str = match intercepted {
            None | Some(SetLine::PassThrough) => cmd,
            Some(SetLine::Swallow) => return,
            Some(SetLine::Rebuild(line)) => {
                rebuilt = line;
                &rebuilt
            }
        };

        // `:perf` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).

        // stays inline: :set background= reloads theme + recomputes; intricate enough to justify legacy intercept.
        if let Some(rest) = cmd.strip_prefix("set background=") {
            match rest.trim() {
                "dark" => {
                    self.apply_colorscheme("dark");
                    self.bus.info("background=dark");
                    return;
                }
                "light" => {
                    self.apply_colorscheme("light");
                    self.bus.info("background=light");
                    return;
                }
                other => {
                    self.bus
                        .error(format!("E: unknown background value: {other}"));
                    return;
                }
            }
        }

        // `:colorscheme [name]` / `:colo` — migrated to the host registry
        // (ex_host_cmds.rs `ColorschemeCmd`). `:set background=` above still
        // calls `apply_colorscheme` directly.

        // `:picker` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).
        // `:rg [pattern]` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).
        // `:b [num|name]` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).

        // Multi-buffer commands — canonical names from COMMAND_NAMES table.
        // NOTE: bnext, bprevious/bNext, bfirst, blast, buffers/ls/files, clipboard,
        // bpicker, b <arg> are handled by HostCmd impls in ex_host_cmds.rs (Phase 4c/4d2).
        // NOTE: wall/qall/qall!/wqall/wqall! are KEPT here — hjkl-ex returns
        // single-buffer Save/Quit effects which diverge from the app's write_all /
        // quit_all semantics that iterate all slots or set exit_requested directly.
        match cmd {
            // TODO(4c): `:b#` cannot be migrated — split_name_args treats the
            // trailing `#` as an arg, so the resolved name is never "b#".
            // Leave this legacy arm in place until parse::split_name_args is
            // extended to accept a trailing `#` for buffer commands.
            "b#" => {
                self.buffer_alt();
                return;
            }
            "wall" => {
                self.write_all();
                return;
            }
            "qall" => {
                self.quit_all(false);
                return;
            }
            "qall!" => {
                self.quit_all(true);
                return;
            }
            "wqall" => {
                self.write_quit_all(false);
                return;
            }
            "wqall!" => {
                self.write_quit_all(true);
                return;
            }
            // `:debug` — toggle global debug mode (catch-all diagnostic flag).
            // `:debug on` / `:debug off` set it explicitly. Currently the
            // explorer reads it to render its raw buffer (no glyph overlay).
            "debug" => {
                self.debug_mode = !self.debug_mode;
                self.bus.info(format!(
                    "debug mode {}",
                    if self.debug_mode { "on" } else { "off" }
                ));
                return;
            }
            "debug on" => {
                self.debug_mode = true;
                self.bus.info("debug mode on");
                return;
            }
            "debug off" => {
                self.debug_mode = false;
                self.bus.info("debug mode off");
                return;
            }
            _ => {}
        }

        // `:checktime` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).

        // `:write[!]` — intercept before the engine to enforce disk-state guard.
        if cmd == "write"
            || cmd == "write!"
            || cmd.starts_with("write ")
            || cmd.starts_with("write!")
        {
            let force = cmd == "write!" || cmd.starts_with("write!");
            let path_arg = if let Some(rest) = cmd.strip_prefix("write!") {
                rest.trim()
            } else if let Some(rest) = cmd.strip_prefix("write ") {
                rest.trim()
            } else {
                ""
            };
            let target = if path_arg.is_empty() {
                None
            } else {
                Some(PathBuf::from(path_arg))
            };
            if !force && self.active().disk_state == DiskState::ChangedOnDisk {
                self.bus
                    .error("E13: file has changed on disk (add ! to override)");
                return;
            }
            if self.do_save_force(target, force) {
                self.active_mut().disk_state = DiskState::Synced;
            }
            return;
        }

        // `:sp[lit]` / `:vsp[lit]` — migrated to Phase 4b host registry (ex_host_cmds.rs).
        // `:vnew` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).
        // `:close` / `:only` — migrated to Phase 4b host registry (ex_host_cmds.rs).
        // `:new` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).

        // ─── Tab commands ────────────────────────────────────────────────────

        // `:tabnew` / `:tabedit` / `:tabe` — migrated to Phase 4b host registry (ex_host_cmds.rs).
        // `:tabnext` / `:tabn` — migrated to Phase 4a host registry (ex_host_cmds.rs).
        // `:tabprev` / `:tabp` / `:tabN` — migrated to Phase 4b host registry (ex_host_cmds.rs).
        // `:tabclose` / `:tabc` — migrated to Phase 4b host registry (ex_host_cmds.rs).
        // `:tabfirst` / `:tabrewind` / `:tabr` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).
        // `:tablast` — migrated to Phase 4d2 host registry (ex_host_cmds.rs).

        // `:tabonly` / `:tabo` — migrated to Phase 4f host registry (ex_host_cmds.rs).

        // `:tabmove` — migrated to Phase 4b host registry (ex_host_cmds.rs).

        // `:tabs` — migrated to Phase 4f host registry (ex_host_cmds.rs).

        // `:resize [+|-]N` — migrated to Phase 4f host registry (ex_host_cmds.rs).
        // `:vertical resize [+|-]N` / `:vert res [+|-]N` — migrated to Phase 4f host registry.

        // ── LSP / diag / Anvil — migrated to Phase 4f host registry (ex_host_cmds.rs) ───
        // `:Rename <newname>`, `:LspFormat`, `:Format`, `:LspCodeAction`, `:CodeAction`,
        // `:lopen`, `:lnext`, `:lprev`, `:lfirst`, `:llast`, `:LspInfo`, `:Anvil [...]`

        // Phase 4a–4d2: try the app-level host registry first.
        // Commands live in ex_host_cmds.rs; see host_registry() for the full list.
        {
            let host_reg = ex_host_cmds::host_registry();
            if let Some(eff) = hjkl_ex::try_dispatch_host(host_reg, self, cmd) {
                match eff {
                    ExEffect::EditFile { path, force } => {
                        self.do_edit(&path, force);
                        return;
                    }
                    ExEffect::BufferDelete { force, wipe } => {
                        if wipe {
                            self.buffer_wipe(force);
                        } else {
                            self.buffer_delete(force);
                        }
                        return;
                    }
                    other => {
                        self.sync_viewport_from_editor();
                        self.handle_ex_effect(other);
                        return;
                    }
                }
            }
        }

        // Remember the `:set` body so a successful apply can be written back
        // to the user's config file below. Captured before dispatch because
        // `cmd` borrows a buffer that the dispatch consumes.
        let set_body: Option<String> = cmd
            .strip_prefix("set ")
            .map(|b| b.trim().to_string())
            .filter(|b| !b.is_empty());

        // hjkl-ex is the sole dispatcher — no legacy fallback. Runs against the
        // focused window's editor (#151 Phase D) so `:set`, `:42`, `:s`, register
        // ex commands, etc. mutate the per-window editor that render reads.
        let new_reg = hjkl_ex::default_registry::<TuiHost>();
        let effect =
            if let Some(eff) = hjkl_ex::try_dispatch(&new_reg, self.active_editor_mut(), cmd) {
                match eff {
                    ExEffect::EditFile { path, force } => {
                        self.do_edit(&path, force);
                        return;
                    }
                    ExEffect::BufferDelete { force, wipe } => {
                        if wipe {
                            self.buffer_wipe(force);
                        } else {
                            self.buffer_delete(force);
                        }
                        return;
                    }
                    other => other,
                }
            } else {
                // No command matched — surface as E492.
                ExEffect::Unknown(cmd.to_string())
            };
        // ex commands like `:100` (goto-line), `:/pat` (search address),
        // and `:nohl` mutate engine cursor / viewport without flipping
        // the dirty flag — they return ExEffect::Ok. The window cursor
        // cache (used at render time) must be re-synced or the cursor
        // appears stuck at its pre-`:` position even though the engine
        // moved it.
        self.sync_viewport_from_editor();
        // A `:set` that applied cleanly is written back to the user's config
        // file, so the session and the file never disagree. Runs before
        // `handle_ex_effect` only because that consumes `effect`; the guard is
        // on `effect` either way — a rejected `:set` must not persist.
        // A live `:set filetype=…` is buffer-local state the engine applies
        // only to the focused window's editor; mirror it onto the slot
        // template and re-attach grammar + LSP — vim's FileType autocmd,
        // which the bare engine `:set` never fired.
        if let Some(body) = set_body
            && !matches!(effect, ExEffect::Error(_) | ExEffect::Unknown(_))
        {
            self.persist_set_options(&body);
            if Self::set_body_names_filetype(&body) {
                self.apply_live_filetype_set();
            }
        }
        self.handle_ex_effect(effect);
    }

    /// True when a `:set` body names the `filetype` / `ft` option. The query
    /// form (`ft?`) is excluded — it reads the option, it does not set it.
    fn set_body_names_filetype(body: &str) -> bool {
        body.split_whitespace().any(|tok| {
            let name = tok.split(['=', '+', '-']).next().unwrap_or(tok);
            matches!(name, "ft" | "filetype")
        })
    }

    /// Write every persistable option named in a `:set` line back to the
    /// user's config file.
    ///
    /// Values are read off the live settings rather than re-parsed from the
    /// tokens, so what lands in the file is what the editor actually ended up
    /// with — clamped, normalised, and with `formatoptions+=`/`-=` folded in.
    ///
    /// Buffer-local options (`filetype`, `readonly`, …) and query tokens
    /// (`name?`) resolve to no config key and are skipped silently: they are
    /// session-scoped by design, so there is nothing to write and nothing to
    /// warn about.
    ///
    /// A failure to write is reported once per `:set` line, not once per
    /// token — a read-only config file would otherwise emit a wall of
    /// identical warnings for `:set nu rnu cul`.
    fn persist_set_options(&mut self, body: &str) {
        let Some(path) = self.config_path.clone() else {
            return;
        };
        let mut first_error: Option<String> = None;
        for token in body.split_whitespace() {
            let Some(name) = hjkl_app::config::setting_name_from_token(token) else {
                continue;
            };
            let settings = self.active_editor().settings().clone();
            if let Err(e) = hjkl_app::config::persist_option(&path, name, &settings)
                && first_error.is_none()
            {
                first_error = Some(e.to_string());
            }
        }
        if let Some(e) = first_error {
            self.bus.warn(format!("couldn't save :set to config: {e}"));
        }
    }

    /// Apply the side-effects encoded in an [`ExEffect`] value.
    ///
    /// Extracted from `dispatch_ex` so both the host-registry path and the
    /// editor-registry path can share identical effect handling without
    /// duplicating the match arms.
    fn handle_ex_effect(&mut self, effect: ExEffect) {
        match effect {
            ExEffect::None => {}
            ExEffect::Ok => {}
            ExEffect::Save => {
                self.do_save(None);
            }
            ExEffect::SaveAs(path) => {
                self.do_save(Some(PathBuf::from(path)));
            }
            ExEffect::Quit { force, save } => {
                if save && !self.do_save_force(None, force) {
                    // Save failed (E32 / E45 / IO error). Status_message
                    // already set by do_save; refuse to exit so the user
                    // doesn't lose unsaved content.
                    return;
                }
                // Vim parity: :q with multiple windows closes the focused
                // window (same as :close). :q! with multiple windows also
                // just closes the focused window (force discards dirty state
                // for that window but doesn't quit the app).
                //
                // Whether there IS another window to fall back on is
                // `close_focused_window_checked`'s call, not this site's: an
                // open dock is a leaf too, so counting leaves here would turn
                // `:q` in the last editor into a window close that leaves the
                // dock as the last window. A refusal means "this really was
                // the last window", and quitting/`:bdelete` continues below.
                //
                // It also subsumes the old explicit dock branch: `:q` in a
                // dock closes that dock (like `:close` / `<C-w>c`, and like
                // vim in a quickfix window) rather than falling through to the
                // `buffer_delete` below and destroying the dock's scratch
                // slot.
                if self.close_focused_window_checked().is_ok() {
                    return;
                }
                // E4: multi-slot — close active slot, stay in app. Counts real
                // buffers only: an open explorer or `:copen` dock must not
                // turn `:q` on the last file into a `:bdelete` that leaves the
                // dock's scratch buffer showing in the main window.
                if self.real_slot_count() > 1 {
                    self.buffer_delete(force);
                    return;
                }
                // Last slot, last window: original quit semantics.
                if force || save {
                    self.exit_requested = true;
                } else if self.active().dirty {
                    self.bus
                        .error("E37: No write since last change (add ! to override)");
                } else {
                    self.exit_requested = true;
                }
            }
            ExEffect::Substituted {
                count,
                lines_changed,
            } => {
                // Engine applied the substitution in-place; propagate dirty
                // and fan ContentEdits into the syntax tree.
                let aslot = self.focused_slot_idx();
                if self.slots[aslot].take_dirty() {
                    let elapsed = self.slots[aslot].refresh_dirty_against_saved();
                    self.last_signature_us = elapsed;
                    let buffer_id = self.slots[aslot].buffer_id;
                    if self.slots[aslot].take_content_reset() {
                        self.syntax.reset(buffer_id);
                    }
                    let edits = self.slots[aslot].take_content_edits();
                    if !edits.is_empty() {
                        self.syntax.apply_edits(buffer_id, &edits);
                    }
                    self.lsp_notify_change_active(&edits);
                    self.rebase_sibling_cursors(&edits);
                    self.recompute_and_install();
                }
                if count == 0 {
                    self.bus.warn("Pattern not found");
                } else {
                    self.bus
                        .info(format!("{count} substitutions on {lines_changed} lines"));
                }
            }
            ExEffect::InfoTitled { title, content } => {
                self.info_popup = Some(InfoPopup::new(title, content));
            }
            ExEffect::Info(msg) => {
                if msg.contains('\n') {
                    self.info_popup = Some(InfoPopup::new("info", msg));
                } else {
                    self.bus.info(msg);
                }
            }
            ExEffect::Error(msg) => {
                self.bus.error(format!("E: {msg}"));
            }
            ExEffect::Unknown(c) => {
                self.bus.error(format!("E492: Not an editor command: :{c}"));
            }
            ExEffect::EditFile { path, force } => {
                self.do_edit(&path, force);
            }
            ExEffect::BufferDelete { force, wipe } => {
                if wipe {
                    self.buffer_wipe(force);
                } else {
                    self.buffer_delete(force);
                }
            }
            ExEffect::PutRegister {
                reg,
                above,
                target_line,
            } => {
                self.do_put_register(reg, above, target_line);
            }
            ExEffect::SaveAndRename { path } => {
                // `:saveas {path}`: write to path AND update the buffer's identity.
                // save_slot already updates slot.filename when saving to a new path.
                // Not writing own file → readonly check won't fire; force=false fine.
                let p = PathBuf::from(&path);
                let idx = self.focused_slot_idx();
                self.save_slot(idx, Some(p), false);
            }
            ExEffect::RenameBuffer { name } => {
                // `:file {name}`: rename buffer in-memory without writing.
                let idx = self.focused_slot_idx();
                let p = PathBuf::from(&name);
                self.slots[idx].filename = Some(p.clone());
                self.slots[idx].git_repo_present = None; // re-probe for new path
                self.registers.lock().unwrap().set_filename(Some(name));
                self.bus.info(format!("\"{}\" [Not edited]", p.display()));
            }
            ExEffect::Cwd(new_cwd) => {
                // `:cd` already applied std::env::set_current_dir; show new path.
                self.bus.info(new_cwd);
            }
            ExEffect::Redraw { clear } => {
                if clear {
                    // `:redraw!` — clear the terminal before the next draw.
                    self.force_clear_screen = true;
                }
                // `:redraw` (no `!`) — ratatui's diff-based renderer will
                // repaint on the next event-loop tick without a full clear.
            }
            ExEffect::Preserve => {
                // Force-write the swap for the active slot immediately.
                let idx = self.focused_slot_idx();
                self.write_swap_for_slot(idx);
            }
            ExEffect::Recover(path) => {
                self.do_recover(&path);
            }
            ExEffect::Quickfix(cmd) => {
                self.handle_quickfix_command(cmd);
            }
            ExEffect::Location(cmd) => {
                self.handle_loclist_command(cmd);
            }
            ExEffect::SubstituteConfirm { matches } => {
                if matches.is_empty() {
                    self.bus.warn("Pattern not found");
                    return;
                }
                let len = matches.len();
                // Jump cursor to the first match so it is visible.
                //
                // `row` / `byte_start` were recorded when the match set was
                // collected, and nothing structurally prevents the buffer from
                // having changed since. A stale row would panic in
                // `rope.line()` and a stale (or non-boundary) byte offset would
                // panic slicing the line, so both are treated as untrusted:
                // clamp the row, and fall back to the line's char count when
                // the offset does not name a real prefix. Landing the cursor in
                // the wrong place is recoverable; taking down the editor over a
                // `:s///c` prompt is not.
                let (first_row, first_col) = {
                    let rope = hjkl_engine::Query::rope(self.active_editor().buffer());
                    let row = (matches[0].row as usize).min(rope.len_lines().saturating_sub(1));
                    let line = rope_line_str(&rope, row);
                    let col = line
                        .get(..matches[0].byte_start as usize)
                        .map_or_else(|| line.chars().count(), |p| p.chars().count());
                    (row, col)
                };
                self.active_editor_mut().jump_cursor(first_row, first_col);
                self.sync_after_engine_mutation();

                self.confirming_substitute = Some(crate::app::ConfirmingSubstitute {
                    matches,
                    accepted: vec![false; len],
                    idx: 0,
                });
                // Status line renders the prompt by reading confirming_substitute directly.
            }
        }
    }

    /// `:[range]put[!] [{reg}]` — paste register contents linewise at the
    /// addressed line (B3). `target_line` is the range's resolved 1-based
    /// line address (`Some(0)` is vim's special "before line 1" address for
    /// `:0put`; `None` means no range was given — use the cursor's line).
    /// Without `!`: paste below the addressed line. With `!`: paste above.
    /// Cursor lands on the first non-blank of the LAST pasted line
    /// (verified against real nvim).
    fn do_put_register(&mut self, reg: char, above: bool, target_line: Option<usize>) {
        use hjkl_buffer::{Edit, Position};
        let idx = self.focused_slot_idx();
        // Register bank is a session-shared Arc, same one the active editor
        // is wired to — read it directly (#151 Stage 2b, rule 4).
        let slot_text = self
            .registers
            .lock()
            .unwrap()
            .read(reg)
            .map(|s| s.text.clone())
            .unwrap_or_default();
        if slot_text.is_empty() {
            self.bus.warn(format!("E: register \"{reg}\" is empty"));
            return;
        }
        // Strip trailing newline that linewise yanks carry so we don't
        // introduce a blank line at the end.
        let text = slot_text.trim_end_matches('\n').to_string();
        let pasted_lines = text.matches('\n').count() + 1;
        // `idx` is always the focused slot, so the focused window's own
        // editor is the right (and only meaningfully cursor-visible) target
        // for the paste (#151 Stage 2b — was the slot bridge editor).
        let editor = self.active_editor_mut();
        let (cursor_row, _) = editor.cursor();
        // Resolve the 1-based target line: an explicit range wins, else the
        // cursor's own line (1-based). `0` (`:0put`) always means "before
        // line 1", regardless of `above` — there's no "line -1" to paste
        // after, so the bang flag is moot for that address (verified
        // against real nvim: `:0put` and `:0put!` produce identical output).
        let target_line_1based = target_line.unwrap_or(cursor_row + 1);
        // `first_pasted_row`: the 0-based row the pasted text's FIRST line
        // lands on, used both to perform the edit and to place the cursor
        // afterward.
        let first_pasted_row = if target_line_1based == 0 {
            editor.mutate_edit(Edit::InsertStr {
                at: Position::new(0, 0),
                text: format!("{text}\n"),
            });
            0
        } else {
            let row = target_line_1based - 1;
            if above {
                editor.mutate_edit(Edit::InsertStr {
                    at: Position::new(row, 0),
                    text: format!("{text}\n"),
                });
                row
            } else {
                let paste_rope = editor.buffer().rope();
                let line_len = hjkl_buffer::rope_line_str(&paste_rope, row).chars().count();
                editor.mutate_edit(Edit::InsertStr {
                    at: Position::new(row, line_len),
                    text: format!("\n{text}"),
                });
                row + 1
            }
        };
        // Cursor → first non-blank of the LAST pasted line.
        let last_pasted_row = first_pasted_row + pasted_lines - 1;
        let last_line_col = {
            let rope = editor.buffer().rope();
            let line = hjkl_buffer::rope_line_str(&rope, last_pasted_row);
            line.chars().position(|c| !c.is_whitespace()).unwrap_or(0)
        };
        editor.jump_cursor(last_pasted_row, last_line_col);
        // Sync dirty state and propagate to syntax engine.
        let slot = &mut self.slots[idx];
        if slot.take_dirty() {
            slot.refresh_dirty_against_saved();
        }
    }

    /// `:sp [file]` / `:split [file]` — open a horizontal split.
    ///
    /// With no argument: duplicates the current window (same slot, same
    /// scroll).  With a filename: opens a new slot in the upper half.
    pub(super) fn do_split(&mut self, arg: &str) {
        use crate::app::window::{LayoutTree, SplitDir, Window};
        // Splitting a dock would put a file buffer inside the explorer's or
        // the quickfix window's fixed column/row, at the dock's size — vim
        // doesn't let `:sp` land there either. Reroute to a regular window
        // first, same as buffer-open paths already do.
        self.focus_editor_window_for_open();
        let focused = self.focused_window();
        let cur_slot = self.windows[focused]
            .as_ref()
            .expect("focused_window open")
            .slot;
        // Inherit the source window's scroll from its own editor (#151 Phase D).
        let (top_row, top_col) = self.window_scroll(focused);
        let (cursor_row, cursor_col) = self.active_editor().cursor();

        let new_slot = if arg.is_empty() {
            // Duplicate — same slot.
            cur_slot
        } else {
            match self.open_new_slot(std::path::PathBuf::from(arg)) {
                Ok(idx) => idx,
                Err(msg) => {
                    self.bus.info(msg);
                    return;
                }
            }
        };

        let new_win_id = self.next_window_id;
        self.next_window_id += 1;
        self.windows.push(Some(Window::new(new_slot)));
        self.reconcile_window_editors();
        self.seed_window_editor(new_win_id, cursor_row, cursor_col, top_row, top_col);
        // Inherit the source window's fold state on a same-slot duplicate
        // (vim default). A split onto a different file starts fresh.
        if new_slot == cur_slot
            && let Some(folds) = self.window_folds.get(&focused).cloned()
        {
            self.window_folds.insert(new_win_id, folds);
        }
        // Replace the focused leaf with a horizontal split:
        // new window on top (a), existing window below (b).
        self.layout_mut()
            .replace_leaf(focused, move |id| LayoutTree::Split {
                dir: SplitDir::Horizontal,
                ratio: 0.5,
                fixed: None,
                a: Box::new(LayoutTree::Leaf(new_win_id)),
                b: Box::new(LayoutTree::Leaf(id)),
                last_rect: None,
            });
        self.set_focused_window(new_win_id);
        self.bus.info("split");
    }

    /// `:vsp [file]` / `:vsplit [file]` — open a vertical split.
    ///
    /// With no argument: duplicates the current window (same slot, same
    /// scroll).  With a filename: opens a new slot in the left half.
    /// New window goes on the left (vim convention).
    pub(super) fn do_vsplit(&mut self, arg: &str) {
        use crate::app::window::{LayoutTree, SplitDir, Window};
        // See `do_split`: reroute off a dock before splitting (#63 Phase A).
        self.focus_editor_window_for_open();
        let focused = self.focused_window();
        let cur_slot = self.windows[focused]
            .as_ref()
            .expect("focused_window open")
            .slot;
        // Inherit the source window's scroll from its own editor (#151 Phase D).
        let (top_row, top_col) = self.window_scroll(focused);
        let (cursor_row, cursor_col) = self.active_editor().cursor();

        let new_slot = if arg.is_empty() {
            // Duplicate — same slot.
            cur_slot
        } else {
            match self.open_new_slot(std::path::PathBuf::from(arg)) {
                Ok(idx) => idx,
                Err(msg) => {
                    self.bus.info(msg);
                    return;
                }
            }
        };

        let new_win_id = self.next_window_id;
        self.next_window_id += 1;
        self.windows.push(Some(Window::new(new_slot)));
        self.reconcile_window_editors();
        self.seed_window_editor(new_win_id, cursor_row, cursor_col, top_row, top_col);
        // Inherit the source window's fold state on a same-slot duplicate
        // (vim default). A split onto a different file starts fresh.
        if new_slot == cur_slot
            && let Some(folds) = self.window_folds.get(&focused).cloned()
        {
            self.window_folds.insert(new_win_id, folds);
        }
        // Replace the focused leaf with a vertical split:
        // new window on the left (a), existing window on the right (b).
        self.layout_mut()
            .replace_leaf(focused, move |id| LayoutTree::Split {
                dir: SplitDir::Vertical,
                ratio: 0.5,
                fixed: None,
                a: Box::new(LayoutTree::Leaf(new_win_id)),
                b: Box::new(LayoutTree::Leaf(id)),
                last_rect: None,
            });
        self.set_focused_window(new_win_id);
        self.bus.info("vsplit");
    }

    /// `:vnew` — open a vertical split with a fresh empty unnamed buffer.
    pub(super) fn do_vnew(&mut self) {
        use crate::app::window::{LayoutTree, SplitDir, Window};
        // See `do_split`: reroute off a dock before splitting (#63 Phase A).
        self.focus_editor_window_for_open();
        let focused = self.focused_window();
        // Inherit the source window's scroll from its own editor (#151 Phase D).
        let (top_row, top_col) = self.window_scroll(focused);

        // Create a fresh empty unnamed slot. No editor built here (#151
        // Stage 2b — see `build_slot`'s doc): bank wiring and viewport
        // sizing happen on the window editor via `reconcile_window_editors`
        // / `make_view_editor` below, and `seed_window_editor` places the
        // real cursor/scroll.
        use hjkl_buffer::View;

        let new_slot_idx = {
            let buffer_id = self.next_buffer_id;
            self.next_buffer_id += 1;
            let mut slot =
                super::BufferSlot::new(buffer_id, View::new(), hjkl_engine::Settings::default());
            slot.snapshot_saved();
            self.slots.push(slot);
            self.slots.len() - 1
        };

        let new_win_id = self.next_window_id;
        self.next_window_id += 1;
        self.windows.push(Some(Window::new(new_slot_idx)));
        self.reconcile_window_editors();
        self.seed_window_editor(new_win_id, 0, 0, top_row, top_col);
        // New window on the left (a), existing on the right (b).
        self.layout_mut()
            .replace_leaf(focused, move |id| LayoutTree::Split {
                dir: SplitDir::Vertical,
                ratio: 0.5,
                fixed: None,
                a: Box::new(LayoutTree::Leaf(new_win_id)),
                b: Box::new(LayoutTree::Leaf(id)),
                last_rect: None,
            });
        self.set_focused_window(new_win_id);
        self.bus.info("vnew");
    }

    /// `:new` — open a horizontal split with a fresh empty unnamed buffer.
    ///
    /// New window appears on top (a), existing window stays below (b).
    pub(super) fn do_new(&mut self) {
        use crate::app::window::{LayoutTree, SplitDir, Window};
        // See `do_split`: reroute off a dock before splitting (#63 Phase A).
        self.focus_editor_window_for_open();
        let focused = self.focused_window();
        // Inherit the source window's scroll from its own editor (#151 Phase D).
        let (top_row, top_col) = self.window_scroll(focused);

        // Create a fresh empty unnamed slot. No editor built here (#151
        // Stage 2b — see `do_vnew` / `build_slot`'s doc).
        use hjkl_buffer::View;

        let new_slot_idx = {
            let buffer_id = self.next_buffer_id;
            self.next_buffer_id += 1;
            let mut slot =
                super::BufferSlot::new(buffer_id, View::new(), hjkl_engine::Settings::default());
            slot.snapshot_saved();
            self.slots.push(slot);
            self.slots.len() - 1
        };

        let new_win_id = self.next_window_id;
        self.next_window_id += 1;
        self.windows.push(Some(Window::new(new_slot_idx)));
        self.reconcile_window_editors();
        self.seed_window_editor(new_win_id, 0, 0, top_row, top_col);
        // New window on top (a), existing window below (b).
        self.layout_mut()
            .replace_leaf(focused, move |id| LayoutTree::Split {
                dir: SplitDir::Horizontal,
                ratio: 0.5,
                fixed: None,
                a: Box::new(LayoutTree::Leaf(new_win_id)),
                b: Box::new(LayoutTree::Leaf(id)),
                last_rect: None,
            });
        self.set_focused_window(new_win_id);
        self.bus.info("new");
    }

    /// Format a one-line summary of the active clipboard backend for the
    /// status line. Used by `:clipboard`.
    pub(crate) fn clipboard_status(&self) -> String {
        let Some(cb) = self.active_editor().host().clipboard() else {
            return "clipboard: unavailable (probe failed)".into();
        };
        let kind = cb.kind();
        let caps = cb.capabilities();
        let flags = [
            (hjkl_clipboard::Capabilities::WRITE, "WRITE"),
            (hjkl_clipboard::Capabilities::READ, "READ"),
            (hjkl_clipboard::Capabilities::CLEAR, "CLEAR"),
            (hjkl_clipboard::Capabilities::AVAILABLE, "AVAILABLE"),
            (hjkl_clipboard::Capabilities::PRIMARY, "PRIMARY"),
            (hjkl_clipboard::Capabilities::IMAGE, "IMAGE"),
            (hjkl_clipboard::Capabilities::RICH_TEXT, "RICH_TEXT"),
            (hjkl_clipboard::Capabilities::URI_LIST, "URI_LIST"),
            (hjkl_clipboard::Capabilities::ASYNC_WRITE, "ASYNC_WRITE"),
            (hjkl_clipboard::Capabilities::ASYNC_READ, "ASYNC_READ"),
            (hjkl_clipboard::Capabilities::ASYNC_CLEAR, "ASYNC_CLEAR"),
            (
                hjkl_clipboard::Capabilities::ASYNC_AVAILABLE,
                "ASYNC_AVAILABLE",
            ),
        ];
        let active: Vec<&str> = flags
            .iter()
            .filter_map(|(f, name)| caps.contains(*f).then_some(*name))
            .collect();
        format!("clipboard: {kind} | {}", active.join(" "))
    }

    /// Write buffer content to `path` (or `self.active().filename` if `path` is `None`).
    /// `force` corresponds to `:w!` — overrides the readonly E45 guard when writing
    /// the buffer's own file.
    /// Returns `true` on success, `false` on any failure (E32 / E45 / IO error).
    pub(crate) fn do_save(&mut self, path: Option<PathBuf>) -> bool {
        self.do_save_force(path, false)
    }

    /// Like [`do_save`] but with an explicit `force` flag (`:w!`).
    pub(crate) fn do_save_force(&mut self, path: Option<PathBuf>, force: bool) -> bool {
        let idx = self.focused_slot_idx();
        self.save_slot(idx, path, force)
    }

    /// Write slot `idx`'s buffer to `path` (or the slot's own filename if
    /// `path` is `None`). Pushes a notification on success or failure.
    /// `force` overrides the readonly E45 guard when writing the buffer's own file.
    /// Does NOT change `self.active`. Returns `true` on success.
    fn save_slot(&mut self, idx: usize, path: Option<PathBuf>, force: bool) -> bool {
        // Vim semantics: `readonly` only blocks writing the buffer's OWN file,
        // and only when not forced (`:w!` overrides). Writing to a different path
        // (`:w other` / `:saveas`) is always allowed regardless of readonly.
        // `readonly` is per-window (#151): read the focused window editor when
        // saving the focused slot, else the slot's settings template
        // (background save — #151 Stage 2b: no slot-level editor anymore).
        let readonly = if idx == self.focused_slot_idx() {
            self.active_editor().is_readonly()
        } else {
            self.slots[idx].is_readonly()
        };
        if readonly {
            let writing_own =
                path.is_none() || path.as_deref() == self.slots[idx].filename.as_deref();
            if writing_own && !force {
                self.bus
                    .error("E45: 'readonly' option is set (add ! to override)");
                return false;
            }
        }
        let target = path.or_else(|| self.slots[idx].filename.clone());
        match target {
            None => {
                self.bus.error("E32: No file name");
                false
            }
            Some(p) => {
                // ── Pre-save hooks ──────────────────────────────────────────
                //
                // trim_trailing_whitespace runs first so that if format_on_save
                // is also on, the formatter sees already-trimmed input (formatters
                // like rustfmt/prettier normalise trailing whitespace themselves,
                // but trimming first keeps the two hooks independent and correct
                // on their own).
                //
                // Note: `:w!` is not distinguished from `:w` at the ExEffect
                // level in v1 — both hooks always fire when their option is true.
                {
                    // Pre-save hook flags are per-window settings (#151 Phase D):
                    // read the focused window's editor when saving the focused
                    // slot, else fall back to the slot's bridge editor.
                    let s = if idx == self.focused_slot_idx() {
                        self.active_editor().settings().clone()
                    } else {
                        self.slots[idx].settings().clone()
                    };
                    if s.trim_trailing_whitespace
                        && let Some(new_content) = trimmed_trailing_whitespace(&self.slots[idx])
                    {
                        self.set_content_undoable_for_slot(idx, &new_content);
                    }
                    if s.format_on_save
                        && let Some(formatter) = hjkl_mangler::formatter_for_path(&p)
                    {
                        if !hjkl_mangler::is_tool_installed(formatter.tool_name()) {
                            self.bus.warn(format!(
                                "format-on-save: {} not installed, skipping",
                                formatter.tool_name()
                            ));
                        } else {
                            let content = self.slots[idx].buffer().content_joined();
                            // `Path::parent()` of a bare relative filename is
                            // `Some("")`, not `None`; an empty working dir makes
                            // the formatter spawn fail with NotFound (misread as
                            // "not installed"). Normalise empty → `.`.
                            let project_root = match p.parent() {
                                Some(par) if !par.as_os_str().is_empty() => par,
                                _ => std::path::Path::new("."),
                            };
                            match formatter.format(&content, project_root, None) {
                                Ok(formatted) => {
                                    // Strip the single trailing newline that
                                    // formatters (prettier, etc.) append — the
                                    // buffer line model excludes the file-final
                                    // EOL (matching file load), and save re-adds
                                    // it. Without this the buffer gains a phantom
                                    // empty last line after format-on-save.
                                    let formatted =
                                        formatted.strip_suffix('\n').unwrap_or(&formatted);
                                    self.set_content_undoable_for_slot(idx, formatted);
                                }
                                Err(e) => {
                                    self.bus.error(format!("format-on-save error: {e}"));
                                    return false;
                                }
                            }
                        }
                        // No formatter registered for this extension → silent
                        // fall-through (save proceeds unformatted).
                    }
                }
                // ── End pre-save hooks ──────────────────────────────────────

                // The format-on-save / trim hooks may have rewritten the buffer
                // (via `set_content_undoable` → whole-buffer reset, or content
                // edits). Fan those changes into the syntax tree NOW so the next
                // render doesn't query a stale tree (old byte offsets) against
                // the new rope — that mismatch slices a node range mid-char and
                // panics `ropey::byte_slice`.
                {
                    let bid = self.slots[idx].buffer_id;
                    let was_reset = self.slots[idx].take_content_reset();
                    if was_reset {
                        self.syntax.reset(bid);
                    }
                    let edits = self.slots[idx].take_content_edits();
                    if !edits.is_empty() {
                        self.syntax.apply_edits(bid, &edits);
                    }
                    // Rebuild spans for the active buffer before the next draw.
                    if (was_reset || !edits.is_empty()) && idx == self.focused_slot_idx() {
                        self.pending_recompute = true;
                    }
                }

                // Reuse the per-dirty_gen Arc<String> from content_joined() so
                // saves share the same allocation that LSP / git / syntax / dirty
                // signature paths already paid for. Was View::lines().join()
                // which re-cloned every line (162k allocs on a 162k-row buffer).
                // Write in two pieces so the trailing newline doesn't force a
                // full-buffer clone just to push a byte.
                use hjkl_engine::Query;
                let joined = self.slots[idx].buffer().content_joined();
                let body: &[u8] = joined.as_bytes();
                // vim `'endofline'`/`'fixendofline'` — see
                // `save::EolState::trailing_newline` for the full rule (and
                // the one documented divergence from nvim).
                // `fixendofline` is read the same way the pre-save hooks read
                // their flags above: the focused window's editor when saving
                // the focused slot, else the slot's own settings template.
                let fixendofline = if idx == self.focused_slot_idx() {
                    self.active_editor().settings().fixendofline
                } else {
                    self.slots[idx].settings().fixendofline
                };
                let needs_trailing_nl = self.slots[idx].eol.trailing_newline(&joined, fixendofline);
                let line_count = self.slots[idx].buffer().line_count() as usize;
                let byte_count = body.len() + usize::from(needs_trailing_nl);
                // Create parent dir(s) if missing so writing into a fresh
                // path like ~/.config/hjkl/config.toml works first try.
                if let Some(parent) = p.parent()
                    && !parent.as_os_str().is_empty()
                    && !parent.exists()
                    && let Err(e) = std::fs::create_dir_all(parent)
                {
                    self.bus.error(format!("E: {}: {e}", parent.display()));
                    return false;
                }
                let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
                let write_result = save_file_durable(&p, body, needs_trailing_nl, &cwd);
                match write_result {
                    Ok(()) => {
                        self.bus.info(format!(
                            "\"{}\" {}L, {}B written",
                            p.display(),
                            line_count,
                            byte_count,
                        ));
                        // Record disk metadata so checktime knows the new baseline.
                        if let Ok(meta) = std::fs::metadata(&p) {
                            self.slots[idx].disk_mtime = meta.modified().ok();
                            self.slots[idx].disk_len = Some(meta.len());
                        }
                        self.slots[idx].disk_state = DiskState::Synced;
                        self.slots[idx].filename = Some(p.clone());
                        self.slots[idx].git_repo_present = None; // re-probe for new path
                        // Keep `"%` in sync when the buffer gets a (new) filename.
                        self.registers
                            .lock()
                            .unwrap()
                            .set_filename(Some(p.to_string_lossy().into_owned()));
                        self.slots[idx].is_new_file = false;
                        self.slots[idx].snapshot_saved();
                        // Persist the last-moved cursor for cross-session
                        // restore (docs §6b). Best-effort, keyed on the path
                        // just written (slot.filename updated above).
                        self.persist_slot_cursor(idx);
                        // Persist the undo tree for cross-session undo/redo
                        // (docs §6). Best-effort; the buffer IS the tree's
                        // current node at save time, so the persisted `current`
                        // matches the file just written.
                        self.persist_slot_undofile(idx);
                        // Delete the swap file on successful save (#185).
                        if let Some(ref sp) = self.slots[idx].swap_path.clone() {
                            let _ = hjkl_app::swap::remove_swap(sp);
                            self.slots[idx].last_swap_dirty_gen = None;
                        }
                        if idx == self.focused_slot_idx() {
                            self.refresh_git_signs_force();
                        }
                        // Tell the language server the file was saved so its
                        // on-save flycheck (e.g. rust-analyzer clippy) re-runs.
                        self.lsp_notify_save_slot(idx);
                        true
                    }
                    Err(e) => {
                        self.bus.error(format!("E: {}: {e}", p.display()));
                        false
                    }
                }
            }
        }
    }

    /// `:wa` / `:wall` — write all named dirty slots.
    fn write_all(&mut self) {
        let mut written = 0usize;
        let mut skipped = 0usize;
        for i in 0..self.slots.len() {
            if self.slots[i].filename.is_none() {
                skipped += 1;
                continue;
            }
            if !self.slots[i].dirty {
                continue;
            }
            // Only count saves that actually succeeded — an IO/E45 failure
            // must not be reported as "written".
            if self.save_slot(i, None, false) {
                written += 1;
            }
        }
        self.bus
            .info(format!("{written} buffer(s) written, {skipped} skipped"));
    }

    // ── Swap file helpers (issue #185) ────────────────────────────────────────

    /// Write the swap file for slot `idx`.
    ///
    /// Named buffers (with a filename): skip when the slot has no `swap_path`
    /// or the buffer hasn't changed since the last write.
    ///
    /// Scratch buffers (no filename): skip when the buffer is empty (nothing
    /// worth recovering). Otherwise assign a `scratch_<pid>_<bufid>.swp` path
    /// lazily on the first write (leaving `swap_path = None` for pristine
    /// empties avoids littering the swap dir on every `:new`/`:vnew`).
    ///
    /// Errors during write are logged as debug (not shown to user — swap is
    /// best-effort).
    pub(crate) fn write_swap_for_slot(&mut self, idx: usize) {
        use hjkl_app::swap::{self, SwapHeader};

        // `-n`: swap disabled for the session — no-op (issue: `-n`/no-swap).
        if !self.swapfile_enabled {
            return;
        }

        // Special scratch buffers (explorer, quickfix, cmdline) are programmatic
        // — never persist them to a swap file (otherwise a crash would offer to
        // "recover" a tree listing or quickfix history).
        if self.slots[idx].is_special() {
            return;
        }

        let is_scratch = self.slots[idx].filename.is_none();

        if is_scratch {
            // Skip if the buffer is empty — no content worth recovering.
            // byte_len() == 0 means the rope is empty.
            let byte_len = self.slots[idx].buffer().byte_len();
            if byte_len == 0 {
                return;
            }

            // Lazily assign a scratch swap path on the first non-empty write.
            if self.slots[idx].swap_path.is_none() {
                let pid = std::process::id();
                let buffer_id = self.slots[idx].buffer_id;
                match swap::scratch_swap_path_in(&self.swap_root, pid, buffer_id) {
                    Ok(p) => self.slots[idx].swap_path = Some(p),
                    Err(e) => {
                        tracing::debug!(err = %e, "scratch_swap_path failed");
                        return;
                    }
                }
            }
        } else {
            // Named buffer: must already have a swap_path (set by build_slot /
            // arm_swap_on_open). No-op if missing.
            if self.slots[idx].swap_path.is_none() {
                return;
            }
        }

        let swap_path = self.slots[idx]
            .swap_path
            .as_ref()
            .expect("swap_path assigned above, or both branches returned")
            .clone();
        let current_gen = self.slots[idx].buffer().dirty_gen();
        if self.slots[idx].last_swap_dirty_gen == Some(current_gen) {
            return; // Nothing changed since last swap.
        }

        // Cursor lives on the window editor (#151); record the view showing it.
        let (cursor_row, cursor_col) = self.slot_cursor(idx);

        let (canonical_path, file_mtime_unix_ms) = if is_scratch {
            // Scratch swap: empty canonical_path marks it as scratch.
            (String::new(), 0u64)
        } else {
            let filename = self.slots[idx]
                .filename
                .as_ref()
                .expect("!is_scratch implies filename is Some")
                .clone();
            let mtime = self.slots[idx]
                .disk_mtime
                .and_then(|t| {
                    t.duration_since(std::time::SystemTime::UNIX_EPOCH)
                        .ok()
                        .map(|d| d.as_millis() as u64)
                })
                .unwrap_or(0);
            let cp = std::fs::canonicalize(&filename)
                .unwrap_or_else(|_| filename.clone())
                .to_string_lossy()
                .into_owned();
            (cp, mtime)
        };

        let header = SwapHeader {
            magic: SwapHeader::MAGIC,
            version: SwapHeader::VERSION,
            canonical_path,
            file_mtime_unix_ms,
            write_time_unix_ms: swap::now_unix_ms(),
            cursor: (cursor_row as u32, cursor_col as u32),
            writer_pid: std::process::id(),
        };

        let rope = self.slots[idx].buffer().rope();
        // Capture the undo tree + live current node alongside the body (docs
        // §6c) so a crash-`:recover` restores undo/redo, not just the text.
        // `undo_to_serializable` syncs the tree's current to the live rope, so
        // the stored tree's current materializes to exactly this body.
        let (mut tree, current_seq) = self.slots[idx].buffer().undo_to_serializable();
        // Single-node tree ⇒ the root base IS the body (root == current, and
        // `sync_current` stashed the live rope into the root). Drop the base so
        // the document is stored once, not twice; `read_swap_full`
        // re-substitutes the body on recovery.
        swap::dedup_single_node_base(&mut tree, &rope);
        let swap_undo = swap::SwapUndo { tree, current_seq };
        if let Err(e) = swap::write_swap_full(&swap_path, &header, &rope, Some(&swap_undo)) {
            tracing::debug!(path = %swap_path.display(), err = %e, "swap write failed");
            return;
        }
        self.slots[idx].last_swap_dirty_gen = Some(current_gen);
    }

    /// Write the initial swap for a freshly-opened slot so the PID lock exists
    /// immediately (matching vim). No-op when: slot has no filename/swap_path,
    /// a recovery prompt is pending (don't clobber the swap the user is deciding
    /// on), or the slot index is out of range (e.g. removed by a lock-refusal).
    ///
    /// `write_swap_for_slot` already skips the write when `last_swap_dirty_gen
    /// == Some(current_gen)`; on a fresh open `last_swap_dirty_gen` is `None`,
    /// so it always writes — even for an unmodified buffer. That is intentional:
    /// the PID lock must exist immediately so a concurrent second open sees it.
    pub(crate) fn arm_swap_on_open(&mut self, slot_idx: usize) {
        // `-n`: swap disabled for the session — no-op (issue: `-n`/no-swap).
        if !self.swapfile_enabled {
            return;
        }
        if self.pending_recovery.is_some() {
            return;
        }
        if slot_idx >= self.slots.len() {
            return;
        }
        if self.slots[slot_idx].filename.is_none() || self.slots[slot_idx].swap_path.is_none() {
            return;
        }
        self.write_swap_for_slot(slot_idx);
    }

    /// `-n`: disable swap files for the rest of the session (issue: `-n`/
    /// no-swap, mirrors `vim -n` / `nvim -n`). Sets [`Self::swapfile_enabled`]
    /// to `false`, which makes [`Self::write_swap_for_slot`] and
    /// [`Self::arm_swap_on_open`] no-op for the rest of the session, and
    /// removes any swap file already armed on currently-open slots — at
    /// startup that is just slot 0's PID-lock swap, written by
    /// `App::new`'s own `arm_swap_on_open(0)` call before `main` gets a
    /// chance to call this. Mirrors the cleanup loop in
    /// [`Self::cleanup_swaps_on_exit`].
    pub(crate) fn disable_swapfiles(&mut self) {
        self.swapfile_enabled = false;
        for slot in &mut self.slots {
            if let Some(p) = slot.swap_path.take() {
                let _ = hjkl_app::swap::remove_swap(&p);
            }
        }
    }

    /// Remove all slots' swap files. Called on graceful shutdown so a clean
    /// exit leaves no swap (no false recovery next open); a crash/kill bypasses
    /// this and the swap survives for recovery.
    ///
    /// Scratch buffers are covered: once their first non-empty idle write fires,
    /// `swap_path` is `Some` and is removed here on graceful exit just like
    /// named-file swaps. Empty scratch buffers never get a swap_path, so there
    /// is nothing to clean up for them.
    pub(crate) fn cleanup_swaps_on_exit(&mut self) {
        for slot in &mut self.slots {
            if let Some(p) = slot.swap_path.take() {
                let _ = hjkl_app::swap::remove_swap(&p);
            }
        }
    }

    /// Load any orphan scratch swaps (unsaved buffers from a crashed session)
    /// from `dir` into recovered unnamed buffers. Returns the count recovered.
    ///
    /// Each recovered buffer is dirty (nudging the user to `:w <name>`), and the
    /// originating orphan swap file is deleted so it is not re-recovered on the
    /// next launch.  The new buffers are appended as background slots — focus is
    /// NOT switched (user navigates to them via `:bnext` / buffer picker).
    ///
    /// The `_from(dir)` variant accepts a directory for testability without real
    /// XDG I/O.  `recover_orphan_scratch_buffers` scans the app's swap root
    /// (`self.swap_root`).
    ///
    /// NOTE: auto-loads all orphans (MVP). A picker UI for many orphans is
    /// out of scope for issue #185.
    ///
    /// Called once from `main` after construction + config + CLI files are
    /// loaded — NOT from `App::new` (keeps tests and every App::new free of
    /// real-XDG scanning).
    pub(crate) fn recover_orphan_scratch_buffers_from(&mut self, dir: &std::path::Path) -> usize {
        use hjkl_app::swap;

        // The scan is only a CANDIDATE list: its `pid_is_alive` answers are
        // stale the instant it returns, and a fresh process can claim a scratch
        // slot (or rewrite one) between the scan and the removal below. So keep
        // nothing but the paths here and re-decide per file under that file's
        // lock.
        let candidates: Vec<std::path::PathBuf> = swap::scan_orphan_scratch_swaps_in(dir)
            .into_iter()
            .map(|o| o.swap_path)
            .collect();
        if candidates.is_empty() {
            return 0;
        }

        let mut n = 0usize;
        for swap_path in candidates {
            // One exclusive lock per orphan spanning re-check → recover →
            // remove. Locking per file (never two at once) means there is no
            // ordering between orphans to get wrong. `orphan_scratch_at`
            // re-reads under the lock, so both the liveness decision and the
            // recovered content come from the bytes this lock protects. A lock
            // failure just skips that orphan — it stays on disk for next launch.
            let recovered = hjkl_fs::with_lock_exclusive(&swap_path, || {
                Ok(self.recover_one_orphan_locked(&swap_path))
            })
            .unwrap_or(false);
            if recovered {
                n += 1;
            }
        }
        if n == 0 {
            return 0;
        }

        self.bus.info(format!(
            "Recovered {n} unsaved buffer(s) from a previous session"
        ));
        n
    }

    /// Recover the single orphan scratch swap at `swap_path`, with that path's
    /// exclusive lock held by the caller. Returns whether a buffer was recovered
    /// (`false` when the re-check under the lock says it is no longer an orphan
    /// — a live process claimed it, or it became unreadable).
    ///
    /// Must not be called without the lock: it re-reads, decides, and removes.
    fn recover_one_orphan_locked(&mut self, swap_path: &std::path::Path) -> bool {
        use hjkl_app::swap;
        use hjkl_buffer::View;

        // Re-decide INSIDE the lock. The scan said this was a dead writer's
        // scratch swap; that may no longer be true, and removing a live
        // instance's swap would destroy its unsaved work.
        let Some(orphan) = swap::orphan_scratch_at(swap_path) else {
            return false;
        };

        {
            // Build a fresh unnamed slot (mirrors build_slot with path=None).
            // No editor here (#151 Stage 2b): these buffers are pushed as
            // background slots that may never get a window this session
            // (focus is NOT switched — see doc above), so the recovered
            // cursor is stashed directly on the slot's own `View` — exactly
            // what `make_view_editor` reads as the initial cursor whenever a
            // window is eventually opened on this slot (`:bnext`, picker).
            let buffer_id = self.next_buffer_id;
            self.next_buffer_id += 1;
            let mut view = View::new();
            // Install recovered content via BufferEdit::replace_all — a full
            // reset, matching the old `Editor::set_content`'s reset-not-
            // incremental semantics (there's no live syntax tree yet for a
            // slot that was never wired into `App`, so there's nothing to
            // signal a `ContentReset` to here).
            let stripped = orphan.body.strip_suffix('\n').unwrap_or(&orphan.body);
            hjkl_engine::BufferEdit::replace_all(&mut view, stripped);

            // Restore the undo tree if the swap carried one (v3, docs §6c). MUST
            // run after the content is installed. Fail-safe: an inconsistent /
            // absent tree leaves the fresh single-node tree — content only.
            if let Some(u) = &orphan.undo {
                view.install_recovered_undo_tree(&u.tree, u.current_seq);
            }

            // Restore cursor from swap header.
            let (row, col) = orphan.header.cursor;
            let clamped =
                view.clamp_position(hjkl_buffer::Position::new(row as usize, col as usize));
            view.set_cursor(clamped);

            // `swap_path` stays at its `None` default: the idle writer will
            // assign a fresh scratch path for this session on the next edit
            // if needed.
            let mut slot = super::BufferSlot {
                dirty: true, // nudge user to :w as <name>
                ..super::BufferSlot::new(buffer_id, view, hjkl_engine::Settings::default())
            };
            slot.snapshot_saved();
            // Re-mark dirty after snapshot (snapshot_saved clears dirty).
            slot.dirty = true;
            self.slots.push(slot);
        }

        // Delete the orphan swap so it won't recover again next launch. Still
        // under the caller's lock, so this deletes exactly the bytes recovered
        // above — not a swap some other instance wrote in the meantime.
        let _ = swap::remove_swap(swap_path);
        true
    }

    /// Convenience wrapper: scan the app's swap root (`self.swap_root`).
    ///
    /// Called once from `main` after construction — NOT from `App::new`.
    pub(crate) fn recover_orphan_scratch_buffers(&mut self) -> usize {
        match self.swap_root.dir() {
            Ok(d) => self.recover_orphan_scratch_buffers_from(&d),
            Err(_) => 0,
        }
    }

    /// `:recover [file]` — explicit swap-file recovery.
    ///
    /// Empty arg → force recovery on the current buffer's swap (bypasses the
    /// mtime-newer gate).
    /// Non-empty arg → open/switch to that file via `do_edit`, then force
    /// recovery on the resulting slot.
    /// No swap found → reports an info message to the user.
    pub(crate) fn do_recover(&mut self, path: &str) {
        use hjkl_app::swap;

        if path.is_empty() {
            // Recover the current buffer.
            let slot_idx = self.focused_slot_idx();

            // Guard: must have a filename and a swap path.
            let swap_path = match self.slots[slot_idx].swap_path.as_ref() {
                Some(p) => p.clone(),
                None => {
                    let name = self
                        .active()
                        .filename
                        .as_ref()
                        .map_or_else(|| "[No Name]".into(), |p| p.display().to_string());
                    self.bus.info(format!("No swap file found for {name}"));
                    return;
                }
            };
            if !swap_path.exists() {
                let name = self
                    .active()
                    .filename
                    .as_ref()
                    .map_or_else(|| "[No Name]".into(), |p| p.display().to_string());
                self.bus.info(format!("No swap file found for {name}"));
                return;
            }

            // Try to read the swap — if unreadable, treat as no-swap.
            if let Err(e) = swap::read_swap(&swap_path) {
                tracing::debug!(%e, ":recover failed to read swap");
                let name = self
                    .active()
                    .filename
                    .as_ref()
                    .map_or_else(|| "[No Name]".into(), |p| p.display().to_string());
                self.bus.info(format!("No swap file found for {name}"));
                return;
            }

            // Force recovery (skip stale-mtime gate).
            let pending = self.force_recovery_on_open(slot_idx);
            if !pending {
                // force_recovery_on_open only returns false when the swap is
                // absent or unreadable — covered above; shouldn't reach here.
                let name = self
                    .active()
                    .filename
                    .as_ref()
                    .map_or_else(|| "[No Name]".into(), |p| p.display().to_string());
                self.bus.info(format!("No swap file found for {name}"));
            }
        } else {
            // Open/switch to the specified file, then force recovery on it.
            self.do_edit(path, false);
            // After do_edit the focused slot is the newly opened (or switched-to)
            // slot. Force recovery regardless of mtime.
            let slot_idx = self.focused_slot_idx();
            let swap_path_exists = self.slots[slot_idx]
                .swap_path
                .as_ref()
                .is_some_and(|p| p.exists());
            if !swap_path_exists {
                self.bus.info(format!("No swap file found for {path}"));
                return;
            }
            let pending = self.force_recovery_on_open(slot_idx);
            if !pending {
                self.bus.info(format!("No swap file found for {path}"));
            }
        }
    }

    /// Apply a bundled colorscheme by name, swapping the WHOLE [`AppTheme`] —
    /// UI chrome (`self.theme.ui`) AND the syntax layer — then reinstalling the
    /// visible spans and recording the name for `:colorscheme?`. Returns `false`
    /// (leaving the current theme untouched) when `name` is not a registered
    /// scheme, so callers can emit `E185` / a startup warning.
    ///
    /// This is the single source of truth for a theme switch: startup (`main`),
    /// `:colorscheme <name>`, and `:set background=` all route through it, which
    /// is what fixes the historical bug where the chrome didn't follow the
    /// syntax when the scheme changed.
    pub(crate) fn apply_named_theme(&mut self, name: &str) -> bool {
        let Some(theme) = crate::theme::load_named(name) else {
            return false;
        };
        self.theme = theme;
        // `AppTheme::syntax` is `Arc<DotFallbackTheme>`; it unsize-coerces to the
        // `Arc<dyn Theme + Send + Sync>` the syntax layer stores.
        self.syntax.set_theme(self.theme.syntax.clone());
        self.recompute_and_install();
        self.colorscheme = name.to_string();
        true
    }

    /// Switch the active colorscheme (UI chrome + syntax) to a bundled scheme
    /// and record the name for `:colorscheme?`. Unknown names are ignored (the
    /// caller validated via [`crate::theme::load_named`] first). Shared by
    /// `:set background=` and `:colorscheme`.
    pub(crate) fn apply_colorscheme(&mut self, name: &str) {
        self.apply_named_theme(name);
    }

    /// Check whether opening `slot_idx` (which has just been loaded) requires
    /// a recovery prompt.  If a swap file newer than the on-disk content exists,
    /// sets `self.pending_recovery` and returns `true` (caller should not show
    /// normal "N lines" message).  Returns `false` when no recovery is needed.
    ///
    /// Stale swaps (older than the on-disk file) are deleted silently.
    ///
    /// When `force` is `true` the stale-delete branch is skipped — the user
    /// explicitly asked for recovery (`:recover`) regardless of mtime.
    pub(crate) fn check_recovery_on_open(&mut self, slot_idx: usize) -> bool {
        self.check_recovery_on_open_inner(slot_idx, false)
    }

    /// Force-recovery variant of [`check_recovery_on_open`]: bypasses the
    /// mtime-newer gate so the recovery prompt appears even when the on-disk
    /// file looks newer than the swap (used by `:recover`).
    pub(crate) fn force_recovery_on_open(&mut self, slot_idx: usize) -> bool {
        self.check_recovery_on_open_inner(slot_idx, true)
    }

    fn check_recovery_on_open_inner(&mut self, slot_idx: usize, force: bool) -> bool {
        let filename = match self.slots[slot_idx].filename.as_ref() {
            Some(p) => p.clone(),
            None => return false,
        };
        let swap_path = match self.slots[slot_idx].swap_path.as_ref() {
            Some(p) => p.clone(),
            None => return false,
        };
        if !swap_path.exists() {
            return false;
        }
        // ONE exclusive lock across the whole read → decide → act. Everything
        // below decides from bytes read at the top (writer pid, write time) and
        // then acts on the file — `remove_swap` for a stale swap. Without a lock
        // spanning both halves another instance can write a *fresh* swap in the
        // gap and have it deleted here, destroying its record of unsaved work.
        //
        // Exclusive from the start, not shared-then-upgrade: the sequence may
        // write and the lock has no upgrade path. The inner `read_swap_full`
        // (shared) and `remove_swap` (exclusive) re-enter this guard on the same
        // thread and are satisfied by it.
        //
        // A lock failure (I/O on the sidecar) degrades to "no recovery", the
        // same fail-safe every other error in this function takes.
        hjkl_fs::with_lock_exclusive(&swap_path, || {
            Ok(self.check_recovery_locked(slot_idx, force, &filename, &swap_path))
        })
        .unwrap_or(false)
    }

    /// The read → decide → act body of [`check_recovery_on_open_inner`], run
    /// with `swap_path`'s exclusive lock held by the caller. Split out only so
    /// the lock scope is a single obvious expression; it must not be called
    /// without that lock.
    fn check_recovery_locked(
        &mut self,
        slot_idx: usize,
        force: bool,
        filename: &std::path::Path,
        swap_path: &std::path::Path,
    ) -> bool {
        use hjkl_app::swap;

        let (header, body, undo) = match swap::read_swap_full(swap_path) {
            Ok(r) => r,
            Err(e) => {
                tracing::debug!(%e, "failed to read swap on open");
                return false;
            }
        };

        // PID-lock check: if another LIVE process wrote this swap, open the
        // buffer READ-ONLY (vim's "[O]pen Read-Only" for a locked swap). This
        // is uniform across single-file, multi-file, and `:e` opens: the file
        // the user requested stays visible but can't be `:w`-saved over the
        // owning instance. The slot's swap_path is cleared so this process
        // never overwrites the owner's swap. We do NOT remove the slot —
        // dropping an explicitly-requested file (e.g. `hjkl a b c`) would be
        // surprising, and a sole buffer can't be removed at all.
        let our_pid = std::process::id();
        if header.writer_pid != our_pid && swap::pid_is_alive(header.writer_pid) {
            let name = filename.display().to_string();
            let pid = header.writer_pid;
            self.slots[slot_idx].settings_mut().readonly = true;
            // readonly is a per-window setting (#151 Phase D); also set it on the
            // focused window's editor so :w guard + status reflect it immediately.
            if slot_idx == self.focused_slot_idx() {
                self.active_editor_mut().settings_mut().readonly = true;
            }
            self.slots[slot_idx].swap_path = None;
            self.bus.error(format!(
                "E325: \"{name}\" is already open in another hjkl (pid {pid}) — opened read-only"
            ));
            return false;
        }

        // Determine file mtime.
        let file_mtime_ms = std::fs::metadata(filename)
            .ok()
            .and_then(|m| m.modified().ok())
            .and_then(|t| {
                t.duration_since(std::time::SystemTime::UNIX_EPOCH)
                    .ok()
                    .map(|d| d.as_millis() as u64)
            })
            .unwrap_or(0);
        if !force && header.write_time_unix_ms <= file_mtime_ms {
            // Swap is stale (on-disk is newer). Delete silently — safe because
            // the caller's lock has held since `header` was read, so no other
            // instance has written a newer swap here in the meantime.
            let _ = swap::remove_swap(swap_path);
            return false;
        }
        // Swap is newer than disk (or forced) → prompt for recovery.
        let written_ago = format_swap_age(header.write_time_unix_ms);
        self.pending_recovery = Some(super::PendingRecovery {
            header,
            body,
            undo,
            slot_idx,
            written_ago,
        });
        true
    }

    /// Install the recovered swap `body` into slot `slot_idx` and restore the
    /// cursor. Used by the recovery-prompt `y` path.
    ///
    /// MUST signal a full content reset (not an incremental edit): the slot's
    /// syntax tree was parsed against the on-disk content during `build_slot`,
    /// so swapping in the swap body wholesale requires a fresh `parse_initial`.
    /// A raw `BufferEdit::replace_all` only emits an incremental ContentEdit,
    /// which drifts the retained tree against the new bytes → broken
    /// highlighting (#185). `Editor::set_content` sets `pending_content_reset`,
    /// which `sync_after_engine_mutation` routes to `syntax.reset`.
    pub(crate) fn recover_install_content(
        &mut self,
        slot_idx: usize,
        body: &str,
        row: usize,
        col: usize,
        undo: Option<&hjkl_app::swap::SwapUndo>,
    ) {
        // Strip trailing newline — the engine's content format omits it.
        let stripped = body.strip_suffix('\n').unwrap_or(body);
        self.slots[slot_idx].set_content(stripped);
        // Persist the cursor on the slot's own `View` too (#151 Stage 2b) —
        // the source `make_view_editor` reads from for any window opened on
        // this slot LATER (e.g. a background CLI-arg slot not yet switched
        // to). Windows showing it RIGHT NOW are updated explicitly below.
        {
            let clamped = self.slots[slot_idx]
                .buffer()
                .clamp_position(hjkl_buffer::Position::new(row, col));
            self.slots[slot_idx].buffer_mut().set_cursor(clamped);
        }
        // Land the recovered cursor on every window editor showing the slot
        // (#151 View) — the shared-content swap doesn't move their cursors.
        let recover_wins: Vec<usize> = self
            .windows
            .iter()
            .enumerate()
            .filter_map(|(id, w)| w.as_ref().filter(|w| w.slot == slot_idx).map(|_| id))
            .collect();
        for id in recover_wins {
            if let Some(e) = self.window_editors.get_mut(&id) {
                e.jump_cursor(row, col);
            }
        }
        // Restore the undo tree if the swap carried one (v3, docs §6c). MUST run
        // after `set_content` (which resets undo to a single node). Fail-safe:
        // `install_recovered_undo_tree` re-verifies the tree materializes to the
        // recovered content and its current `seq` matches, leaving the fresh
        // single-node tree — content-only recovery — on any inconsistency.
        if let Some(u) = undo {
            self.slots[slot_idx]
                .buffer()
                .install_recovered_undo_tree(&u.tree, u.current_seq);
        }
        self.slots[slot_idx].dirty = true;
    }

    /// Handle a keypress while the recovery prompt is active.
    ///
    /// - `y` → load the swap body into the buffer, mark dirty, keep swap.
    /// - `N` / Esc → load the file fresh (already loaded in slot), dismiss prompt.
    /// - `q` → clear the slot content entirely (abort open).
    pub(crate) fn handle_recovery_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
        use crossterm::event::KeyCode;
        let Some(pr) = self.pending_recovery.as_ref() else {
            return false;
        };
        match key.code {
            KeyCode::Char('y') | KeyCode::Char('Y') => {
                let body = pr.body.clone();
                let slot_idx = pr.slot_idx;
                let (row, col) = pr.header.cursor;
                let undo = pr.undo.clone();
                self.recover_install_content(
                    slot_idx,
                    &body,
                    row as usize,
                    col as usize,
                    undo.as_ref(),
                );
                self.pending_recovery = None;
                self.bus.info("Recovered from swap file. Use :w to save.");
                self.sync_after_engine_mutation();
            }
            KeyCode::Char('q') | KeyCode::Char('Q') => {
                // Abort: remove the slot we just added and switch back if possible.
                let slot_idx = self.pending_recovery.as_ref().map(|p| p.slot_idx);
                self.pending_recovery = None;
                if let Some(idx) = slot_idx {
                    if self.slots.len() > 1 {
                        let removed = self.slots.remove(idx);
                        self.syntax.forget(removed.buffer_id);
                        // The aborted-recovery slot is fully discarded — prune its
                        // changelist bank too (audit B3).
                        self.change_banks.remove(&removed.buffer_id);
                        // Fix window pointers.
                        let slot_count = self.slots.len();
                        for win in self.windows.iter_mut().flatten() {
                            if win.slot >= idx && win.slot > 0 {
                                win.slot -= 1;
                            }
                            win.slot = win.slot.min(slot_count.saturating_sub(1));
                        }
                    } else {
                        // Sole slot (the common `hjkl foo.txt` launch after a
                        // crash): can't remove the only slot, so fall back to
                        // an empty unnamed scratch buffer instead of silently
                        // leaving the on-disk content displayed as if nothing
                        // happened (audit A6).
                        self.lsp_detach_buffer(idx);
                        self.reset_slot_to_scratch(idx);
                        for win in self.windows.iter_mut().flatten() {
                            win.slot = 0;
                        }
                        self.reconcile_window_editors();
                        self.fs_watch_sync();
                    }
                    self.bus.info("Aborted file open.");
                }
            }
            // N, n, Esc → load file fresh (slot already loaded; just dismiss).
            KeyCode::Char('N') | KeyCode::Char('n') | KeyCode::Esc => {
                self.pending_recovery = None;
                self.bus.info("Swap ignored; loaded from disk.");
            }
            _ => {
                // Consume unknown keys — stay in prompt.
            }
        }
        true
    }

    /// Handle a keypress while the dirty-buffer disk-change prompt is active (#241).
    ///
    /// - `k` / Esc → keep the in-memory buffer; the slot stays `ChangedOnDisk`
    ///   so the `:w` guard still applies (use `:e!` to reload later).
    /// - `r` → reload from disk, discarding the buffer's unsaved changes
    ///   (equivalent to `:e!`).
    /// - `d` → open a `:DiffOrig` split of buffer vs disk, then dismiss.
    ///
    /// Returns `true` once the prompt is dismissed (any handled key), so the
    /// caller can request a repaint.
    pub(crate) fn handle_disk_change_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
        use crossterm::event::KeyCode;
        let Some(pdc) = self.pending_disk_change.as_ref() else {
            return false;
        };
        // The prompt only ever targets the focused slot; bail safely if focus
        // drifted (e.g. a mouse click) so we never reload the wrong buffer.
        let targets_focused = pdc.slot_idx == self.focused_slot_idx();
        match key.code {
            KeyCode::Char('r') | KeyCode::Char('R') if targets_focused => {
                self.pending_disk_change = None;
                self.reload_current(true);
                self.sync_after_engine_mutation();
            }
            KeyCode::Char('d') | KeyCode::Char('D') if targets_focused => {
                self.pending_disk_change = None;
                self.diff_orig();
                self.sync_after_engine_mutation();
            }
            KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Esc => {
                self.pending_disk_change = None;
                self.bus
                    .info("Kept buffer; disk change ignored (use :e! to reload).");
            }
            _ => {
                // Unknown key (or action key while focus drifted) — stay in prompt.
            }
        }
        true
    }

    /// `:qa[!]` — quit all. Blocks when any slot is dirty unless `force`.
    ///
    /// `pub(crate)` so `event_loop.rs`'s bare `<C-c>` handler can reuse the
    /// exact same all-slots dirty check and E37 message instead of exiting
    /// unconditionally (see the doc comment at that call site).
    pub(crate) fn quit_all(&mut self, force: bool) {
        // Special scratch buffers (explorer, quickfix, cmdline) are programmatic
        // (no file, never user-saved) — they must never block quit, like they're
        // skipped by :bnext/pickers.
        if !force && let Some(idx) = self.slots.iter().position(|s| s.dirty && !s.is_special()) {
            let name = self.slots[idx]
                .filename
                .as_ref()
                .map_or_else(|| "[No Name]".into(), |p| p.display().to_string());
            self.bus.error(format!(
                "E37: No write since last change for buffer \"{name}\" (add ! to override)"
            ));
            return;
        }
        self.exit_requested = true;
    }

    /// `:wqa[!]` — write all named dirty slots then quit.
    fn write_quit_all(&mut self, force: bool) {
        self.write_all();
        self.quit_all(force);
    }

    /// Open or reload a file via `:e [path]` / `:e!`.
    ///
    /// Switch-or-create semantics (Phase C):
    /// - `:e` with no arg → reload current buffer (blocked when dirty
    ///   unless `force`).
    /// - `:e %` → reload current (`%` expands to current filename).
    /// - `:e <path>` where `<path>` matches an open slot → switch to it.
    /// - `:e <path>` for a new path → load the file in a new slot,
    ///   append, and switch active. The previous slot is untouched.
    pub(crate) fn do_edit(&mut self, arg: &str, force: bool) {
        if arg.is_empty() {
            self.reload_current(force);
            return;
        }
        // `%` expansion already happened ONCE in `dispatch_ex` (via
        // `hjkl_ex::expand_args`), so an arg arriving here may legitimately
        // contain literal `%` characters — e.g. a file named `100%.txt`
        // opened from the picker / quickfix / explorer, or a current
        // filename that itself contains `%`. Re-expanding here corrupted
        // such paths (double expansion). The only bare `%` that survives
        // expansion is the "no current file" case → vim's E499.
        if arg == "%" {
            self.bus.error("E499: Empty file name for '%'");
            return;
        }
        let path_str = arg.to_string();
        let mut path = PathBuf::from(&path_str);
        // Confine `:e` to the working-directory subtree while the RPC
        // filesystem policy is active: `check_fs_path` refuses absolute and
        // `..` paths outright, then `resolve_under` catches symlink escapes a
        // purely lexical check would miss. Every downstream use (slot
        // matching, `open_new_slot`, the reload read) then sees the resolved
        // path. No-op in the TUI, where the policy is off.
        if hjkl_engine::policy::fs_restricted() {
            if let Err(e) = hjkl_engine::policy::check_fs_path(&path) {
                self.bus.error(format!("{path_str}: {e}"));
                return;
            }
            let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
            match hjkl_fs::resolve_under(&cwd, &path) {
                Ok(resolved) => path = resolved,
                Err(e) => {
                    self.bus.error(format!("{path_str}: {e}"));
                    return;
                }
            }
        }
        let target = super::canon_for_match(&path);

        // Switch when the path matches an open slot.
        if let Some(idx) = self
            .slots
            .iter()
            .position(|s| s.filename.as_deref().map(super::canon_for_match) == Some(target.clone()))
        {
            if idx == self.focused_slot_idx() {
                self.reload_current(force);
                return;
            }
            self.switch_to(idx);
            self.bus.info(format!(
                "switched to buffer {}: \"{}\"",
                idx + 1,
                self.active()
                    .filename
                    .as_ref()
                    .map(|p| p.display().to_string())
                    .unwrap_or_default()
            ));
            return;
        }

        // Otherwise create a new slot.
        //
        // Never into a dock: the focused window is about to be pointed at the
        // new slot, and if that window is the explorer / quickfix dock the
        // file lands INSIDE the dock — the window then reports `is_dock` while
        // showing a user file, and the tab still records it as its dock, so a
        // later `:cclose` / `<leader>e` disposes of the window and the slot
        // holding that file. Reroute first, exactly as `:sp`, `q:` and
        // `:DiffOrig` already do (`switch_to` does it for the switch path
        // above).
        self.focus_editor_window_for_open();
        let prev_idx = self.focused_slot_idx();
        let prev_pristine = {
            let s = &self.slots[prev_idx];
            // EVERY special scratch slot (explorer, quickfix/location dock,
            // `q:` cmdline history) is ALSO `filename.is_none() && !dirty` —
            // the same shape as the pristine-default buffer this check exists
            // to discard. Without the `is_special` guard, `:e <path>` with one
            // of those focused would delete that pane's own slot instead of a
            // throwaway default one (audit A8 follow-up; widened from
            // `is_explorer` in #63 Phase 5, which is when quickfix and cmdline
            // slots started satisfying it too).
            s.filename.is_none() && !s.dirty && !s.is_special()
        };
        match self.open_new_slot(path) {
            Ok(new_slot_idx) => {
                // Track alt-buffer before switching.
                self.prev_active = Some(prev_idx);
                // Point the focused window at the new slot.
                let fw = self.focused_window();
                self.windows[fw].as_mut().expect("focused_window open").slot = new_slot_idx;

                // Drop the pristine default buffer once a real file is open.
                if prev_pristine && self.slots.len() > 1 {
                    let removed = self.slots.remove(prev_idx);
                    self.syntax.forget(removed.buffer_id);
                    // The pristine placeholder buffer is fully discarded — prune
                    // its changelist bank too (audit B3).
                    self.change_banks.remove(&removed.buffer_id);
                    // Fix all window slot pointers.
                    let slot_count = self.slots.len();
                    for win in self.windows.iter_mut().flatten() {
                        if win.slot > prev_idx {
                            win.slot -= 1;
                        }
                        win.slot = win.slot.min(slot_count.saturating_sub(1));
                    }
                    self.prev_active = None;
                }

                // Rebuild the focused window's view editor onto the newly opened
                // slot's Buffer (#151 Phase D) before reading active_editor below.
                self.reconcile_window_editors();

                // Recovery check: if a swap file is newer than the on-disk
                // content, enter the recovery prompt instead of reporting
                // normal line count.  The slot is already loaded with the
                // disk content; 'y' replaces it from the swap.
                let current_slot_idx = self.focused_slot_idx();
                let recovery_pending = self.check_recovery_on_open(current_slot_idx);
                if !recovery_pending {
                    // Arm the PID-lock swap immediately on successful open so a
                    // concurrent second instance sees it before any edit is made.
                    self.arm_swap_on_open(current_slot_idx);
                    if !self.suppress_open_notice {
                        let line_count = self.active_editor().buffer().line_count() as usize;
                        let path_display = self
                            .active()
                            .filename
                            .as_ref()
                            .map(|p| p.display().to_string())
                            .unwrap_or_default();
                        self.bus.info(format!("\"{path_display}\" {line_count}L"));
                    }
                }
                self.refresh_git_signs_force();
                // Follow the opened buffer in the explorer (select its row).
                self.explorer_reveal_active();
            }
            Err(msg) => {
                self.bus.info(msg);
            }
        }
    }

    /// Resolve a slot's on-disk path under the RPC filesystem policy before
    /// reading it back — the bare-`:e` reload and `:checktime` autoreload share
    /// this seam. When [`hjkl_engine::policy::fs_restricted()`] is active
    /// (`--nvim-api` / `--embed`), refuse paths that escape the working-directory
    /// subtree, mirroring the gate `do_edit` applies to `:e <path>`, so a buffer
    /// renamed via the RPC API (`nvim_buf_set_name`) to an absolute / outside
    /// path can't be read back. `check_fs_path` lexically refuses every absolute
    /// spelling, but a slot filename may legitimately BE a resolved absolute
    /// path (a file opened via `:e <rel>` stores the canonicalized path), so
    /// `resolve_under` arbitrates: an absolute path that resolves inside the
    /// working directory is a legitimately-opened file, not an escape. No-op in
    /// the TUI (policy off): returns `path` unchanged.
    pub(crate) fn resolve_read_path(
        &self,
        path: &std::path::Path,
    ) -> Result<std::path::PathBuf, String> {
        if !hjkl_engine::policy::fs_restricted() {
            return Ok(path.to_path_buf());
        }
        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        if let Err(e) = hjkl_engine::policy::check_fs_path(path) {
            // Refused spelling — but keep legitimately canonicalized absolute
            // filenames (files opened from inside the cwd) working.
            match hjkl_fs::resolve_under(&cwd, path) {
                Ok(resolved) => return Ok(resolved),
                Err(_) => return Err(format!("{}: {e}", path.display())),
            }
        }
        hjkl_fs::resolve_under(&cwd, path).map_err(|e| format!("{}: {e}", path.display()))
    }

    /// Reload the active slot from disk (`:e` no-arg / `:e %`).
    pub(crate) fn reload_current(&mut self, force: bool) {
        let Some(path) = self.active().filename.clone() else {
            self.bus.error("E32: No file name");
            return;
        };
        // RPC FS-policy gate: a buffer renamed via the RPC API to a path outside
        // the working directory must not be readable back through a bare `:e`
        // reload (the same gate `:e <path>` applies). No-op in the TUI.
        let path = match self.resolve_read_path(&path) {
            Ok(p) => p,
            Err(msg) => {
                self.bus.error(msg);
                return;
            }
        };
        if !force && self.active().dirty {
            self.bus
                .error("E37: No write since last change (add ! to override)");
            return;
        }
        let read = if hjkl_engine::policy::fs_restricted() {
            // RPC mode: the slot's path can point at a file whose size an
            // untrusted client controls, so the reload read is capped.
            hjkl_fs::read_to_string_capped(&path, crate::embed::rpc_read_cap())
        } else {
            hjkl_fs::read_to_string_unbounded(&path)
        };
        let content = match read {
            Ok(c) => c,
            Err(e) => {
                self.bus
                    .error(format!("E484: Can't open file {}: {e}", path.display()));
                return;
            }
        };
        // Re-derive `'endofline'` from the freshly read bytes — a reload is a
        // load (vim resets 'eol' on `:e` too).
        self.active_mut().eol = crate::save::EolState::from_loaded(&content);
        let trimmed = content.strip_suffix('\n').unwrap_or(&content);
        let line_count = trimmed.lines().count();
        let byte_count = content.len();
        // Preserve cursor across reload (vim/nvim :e behaviour). Clamp the
        // saved (row, col) to the new buffer size; reload_current is only
        // called for the active slot so the pre-reload cursor is the user's
        // current position.
        let (prev_row, prev_col) = self.active_editor().cursor();
        self.active_editor_mut().set_content(trimmed);
        let new_rows = self.active_editor().buffer().line_count() as usize;
        let target_row = prev_row.min(new_rows.saturating_sub(1));
        self.active_editor_mut().jump_cursor(target_row, prev_col);
        // Reposition viewport so the restored cursor is visible (with scrolloff).
        // Without this the viewport stays at its pre-reload top_row and the
        // cursor can land offscreen if the file shrank or grew.
        self.active_editor_mut().ensure_cursor_in_scrolloff();
        self.active_mut().is_new_file = false;
        // Record fresh disk metadata and clear the disk-change flag.
        if let Ok(meta) = std::fs::metadata(&path) {
            self.active_mut().disk_mtime = meta.modified().ok();
            self.active_mut().disk_len = Some(meta.len());
        }
        self.active_mut().disk_state = DiskState::Synced;
        let buffer_id = self.active().buffer_id;
        // Non-blocking: Loading case activates via poll_grammar_loads each tick.
        // Re-detect through the filetype seam — a reload is a load (vim
        // re-runs filetype detection on BufRead), so a shebang / modeline `ft=`
        // added since open is picked up here.
        self.redetect_language_for_slot(self.focused_slot_idx(), &content);
        self.syntax.reset(buffer_id);

        self.active_editor_mut().install_ratatui_syntax_spans(&[]);
        // recompute_and_install runs render_viewport sync — no preview warm-up needed.
        self.recompute_and_install();
        self.active_mut().snapshot_saved();
        self.refresh_git_signs_force();
        self.bus.info(format!(
            "\"{}\" {line_count}L, {byte_count}B",
            path.display()
        ));
    }

    /// Check all open slots for external changes. Called on focus-regain
    /// and by `:checktime`. Non-dirty slots whose file changed are reloaded
    /// automatically; dirty slots and deleted files get a status warning
    /// (emitted once per state transition).
    pub(crate) fn checktime_all(&mut self) {
        let mut messages: Vec<String> = Vec::new();
        for idx in 0..self.slots.len() {
            self.checktime_slot(idx, &mut messages);
        }
        if !messages.is_empty() {
            self.bus.info(messages.join(" | "));
        }
    }

    /// Check a single slot for external changes, mirroring [`checktime_all`]'s
    /// per-slot logic. Reloads a clean+autoreload buffer in place (cursor
    /// preserved, syntax/git refreshed); a dirty buffer or `noautoreload` only
    /// flips `disk_state` + pushes a one-shot warning into `messages`; a missing
    /// file flags `DeletedOnDisk`. Returns `true` when the buffer content was
    /// reloaded (so the event-driven caller can request a repaint).
    ///
    /// Shared by the poll path ([`checktime_all`] on focus-regain / `:checktime`)
    /// and the event-driven fs-watch path (`drain_fs_watch_events`).
    pub(crate) fn checktime_slot(&mut self, idx: usize, messages: &mut Vec<String>) -> bool {
        let Some(path) = self.slots[idx].filename.clone() else {
            return false;
        };
        // RPC FS-policy gate: `:checktime` / the fs-watch autoreload must not
        // read back a slot whose filename escapes the working directory
        // (renamed via the RPC API) — refuse before even stat'ing it. No-op in
        // the TUI (policy off).
        let path = match self.resolve_read_path(&path) {
            Ok(p) => p,
            Err(msg) => {
                self.bus.error(msg);
                return false;
            }
        };
        match std::fs::metadata(&path) {
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                let prev = self.slots[idx].disk_state;
                self.slots[idx].disk_state = DiskState::DeletedOnDisk;
                if prev != DiskState::DeletedOnDisk {
                    messages.push(format!("W: \"{}\" deleted on disk", path.display()));
                }
                false
            }
            Err(_) => {
                // Non-NotFound I/O error — skip silently.
                false
            }
            Ok(meta) => {
                let new_mtime = meta.modified().ok();
                let new_len = meta.len();
                // Compare against stored baseline.
                let changed = self.slots[idx].disk_mtime != new_mtime
                    || self.slots[idx].disk_len != Some(new_len);
                if !changed {
                    if self.slots[idx].disk_state == DiskState::DeletedOnDisk {
                        // File reappeared. If we were in Deleted state,
                        // reset to Synced so a follow-up checktime can pick
                        // it up properly (the next block below handles
                        // the actual reload when dirty==false).
                        self.slots[idx].disk_state = DiskState::Synced;
                    }
                    return false;
                }
                // File changed. Warn (don't reload) when the buffer is dirty
                // OR `:set noautoreload` is active; otherwise auto-reload.
                // `autoreload` is a per-window setting (#151 Phase D): read the
                // focused window's editor when this is the focused slot.
                let autoreload = if idx == self.focused_slot_idx() {
                    self.active_editor().settings().autoreload
                } else {
                    self.slots[idx].settings().autoreload
                };
                if self.slots[idx].dirty || !autoreload {
                    let prev = self.slots[idx].disk_state;
                    self.slots[idx].disk_state = DiskState::ChangedOnDisk;
                    if prev != DiskState::ChangedOnDisk {
                        // A modified, focused buffer raises an interactive
                        // keep/reload/diff prompt (#241) instead of just a
                        // warning. Background or clean-but-noautoreload slots
                        // still get the one-shot status warning.
                        if self.slots[idx].dirty
                            && idx == self.focused_slot_idx()
                            && self.pending_disk_change.is_none()
                            && self.pending_recovery.is_none()
                        {
                            self.pending_disk_change = Some(super::PendingDiskChange {
                                slot_idx: idx,
                                path: path.clone(),
                            });
                        } else {
                            let why = if self.slots[idx].dirty {
                                "buffer is dirty, use :e! to reload"
                            } else {
                                "autoreload off, use :e to reload"
                            };
                            messages
                                .push(format!("W: \"{}\" changed on disk ({why})", path.display()));
                        }
                    }
                    false
                } else {
                    // Clean buffer — reload automatically. In RPC mode the read
                    // is capped: the slot's path can point at a file whose size
                    // an untrusted client controls.
                    let read = if hjkl_engine::policy::fs_restricted() {
                        hjkl_fs::read_to_string_capped(&path, crate::embed::rpc_read_cap())
                    } else {
                        hjkl_fs::read_to_string_unbounded(&path)
                    };
                    let Ok(content) = read else {
                        return false;
                    };
                    // An autoread is a load — re-derive `'endofline'` too.
                    self.slots[idx].eol = crate::save::EolState::from_loaded(&content);
                    let trimmed = content.strip_suffix('\n').unwrap_or(&content);
                    // Preserve cursor row + column, clamped to the new
                    // content (vim's autoread keeps the cursor where it was).
                    let (cur_row, cur_col) = {
                        let c = self.slots[idx].buffer().cursor();
                        (c.row, c.col)
                    };
                    self.slots[idx].set_content(trimmed);
                    let new_line_count = self.slots[idx].buffer().line_count() as usize;
                    let clamped_row = cur_row.min(new_line_count.saturating_sub(1));
                    // Slot's own View cursor (#151 Stage 2b — read by
                    // `make_view_editor` for any not-yet-windowed case);
                    // windows showing this slot right now are updated below.
                    {
                        let clamped = self.slots[idx]
                            .buffer()
                            .clamp_position(hjkl_buffer::Position::new(clamped_row, cur_col));
                        self.slots[idx].buffer_mut().set_cursor(clamped);
                    }
                    // Each window editor (#151 View) keeps its own cursor; the
                    // shared-content swap can leave it past EOF — clamp per view.
                    let reload_wins: Vec<usize> = self
                        .windows
                        .iter()
                        .enumerate()
                        .filter_map(|(id, w)| w.as_ref().filter(|w| w.slot == idx).map(|_| id))
                        .collect();
                    for id in reload_wins {
                        if let Some(e) = self.window_editors.get_mut(&id) {
                            let c = e.buffer().cursor();
                            e.jump_cursor(c.row.min(new_line_count.saturating_sub(1)), c.col);
                        }
                    }
                    self.slots[idx].is_new_file = false;
                    // Update disk metadata baseline.
                    self.slots[idx].disk_mtime = new_mtime;
                    self.slots[idx].disk_len = Some(new_len);
                    self.slots[idx].disk_state = DiskState::Synced;
                    self.slots[idx].snapshot_saved();
                    // Refresh syntax + git for the reloaded slot.
                    let buffer_id = self.slots[idx].buffer_id;
                    // Non-blocking: Loading case activates via poll_grammar_loads
                    // each tick. Re-detect through the filetype seam (a reload
                    // is a load), so a shebang / modeline `ft=` gained on disk
                    // is picked up here.
                    self.redetect_language_for_slot(idx, &content);
                    self.syntax.reset(buffer_id);

                    if idx == self.focused_slot_idx() {
                        self.install_syntax_spans_for_slot(idx, &[]);
                        // recompute_and_install runs render_viewport sync — no
                        // preview warm-up needed.
                        self.recompute_and_install();
                        self.refresh_git_signs_force();
                    }
                    messages.push(format!("\"{}\" reloaded from disk", path.display()));
                    true
                }
            }
        }
    }

    // ─── Phase 2 Tab helpers ──────────────────────────────────────────────────

    /// `:tabfirst` / `:tabrewind` / `:tabr` — jump to the first tab.
    pub(super) fn do_tabfirst(&mut self) {
        if self.active_tab == 0 {
            let m = self.tabs.len();
            self.bus.info(format!("tab 1/{m}"));
            return;
        }
        self.switch_tab(0);
        let m = self.tabs.len();
        self.bus.info(format!("tab 1/{m}"));
    }

    /// `:tablast` — jump to the last tab.
    pub(super) fn do_tablast(&mut self) {
        let last = self.tabs.len() - 1;
        if self.active_tab == last {
            let m = self.tabs.len();
            self.bus.info(format!("tab {m}/{m}"));
            return;
        }
        self.switch_tab(last);
        let m = self.tabs.len();
        self.bus.info(format!("tab {m}/{m}"));
    }

    /// `:tabonly` / `:tabo` — close all tabs except the current one.
    pub(super) fn do_tabonly(&mut self) {
        if self.tabs.len() <= 1 {
            self.bus.info("tabonly");
            return;
        }
        // Dispose each closing tab's docks first: a dock owns a scratch slot
        // that the plain window drop below would leak (#63 Phase 3).
        for i in 0..self.tabs.len() {
            if i != self.active_tab {
                self.dispose_tab_docks(i);
            }
        }
        // Collect window ids from all tabs except the active one and drop them.
        for i in 0..self.tabs.len() {
            if i == self.active_tab {
                continue;
            }
            for wid in self.tabs[i].layout.leaves() {
                self.windows[wid] = None;
                self.window_folds.remove(&wid);
            }
        }

        // Keep only the active tab.
        let active_tab = self.tabs[self.active_tab].clone();
        self.tabs = vec![active_tab];
        self.active_tab = 0;
        // Drop window_editors entries for the windows just closed above —
        // otherwise their dead ids keep pinning the old slots' rope Arcs.
        self.reconcile_window_editors();

        self.bus.info("tabonly");
    }

    /// Close all tabs whose index is strictly greater than `active_tab`.
    ///
    /// After this call `self.tabs.len() == self.active_tab + 1`. No-op when
    /// the active tab is already the last one.
    pub(crate) fn close_tabs_to_right(&mut self) {
        let active = self.active_tab;
        if active + 1 >= self.tabs.len() {
            // Already the last tab — nothing to close.
            return;
        }
        // Drop window slots for every tab that lives to the right, docks
        // (and their scratch slots) first.
        for i in (active + 1)..self.tabs.len() {
            self.dispose_tab_docks(i);
        }
        for i in (active + 1)..self.tabs.len() {
            for wid in self.tabs[i].layout.leaves() {
                self.windows[wid] = None;
                self.window_folds.remove(&wid);
            }
        }
        self.tabs.truncate(active + 1);
        // active_tab index is unchanged; it still points to the same tab.
        // Drop window_editors entries for the windows just closed above —
        // otherwise their dead ids keep pinning the old slots' rope Arcs.
        self.reconcile_window_editors();
    }

    /// Close all tabs whose index is strictly less than `active_tab`.
    ///
    /// After this call `self.tabs.len() == self.tabs.len() - active_tab`
    /// and `active_tab == 0`. No-op when the active tab is already the first.
    pub(crate) fn close_tabs_to_left(&mut self) {
        let active = self.active_tab;
        if active == 0 {
            // Already the first tab — nothing to close.
            return;
        }
        // Drop window slots for every tab that lives to the left, docks
        // (and their scratch slots) first.
        for i in 0..active {
            self.dispose_tab_docks(i);
        }
        for i in 0..active {
            for wid in self.tabs[i].layout.leaves() {
                self.windows[wid] = None;
                self.window_folds.remove(&wid);
            }
        }
        // Drain the prefix, shifting remaining tabs to the front.
        self.tabs.drain(0..active);
        self.active_tab = 0;
        // Drop window_editors entries for the windows just closed above —
        // otherwise their dead ids keep pinning the old slots' rope Arcs.
        self.reconcile_window_editors();
    }

    /// `:tabmove [N|+N|-N]` — reorder tabs.
    ///
    /// No arg → move to end. `N` → absolute 0-based position. `+N`/`-N` →
    /// relative. Out-of-range positions are clamped silently.
    pub(super) fn do_tabmove(&mut self, arg: &str) {
        let len = self.tabs.len();
        let target = if arg.is_empty() {
            // No arg: move to end.
            len - 1
        } else if let Some(rest) = arg.strip_prefix('+') {
            let delta: usize = rest.trim().parse().unwrap_or(0);
            (self.active_tab + delta).min(len - 1)
        } else if let Some(rest) = arg.strip_prefix('-') {
            let delta: usize = rest.trim().parse().unwrap_or(0);
            self.active_tab.saturating_sub(delta)
        } else {
            let pos: usize = arg.trim().parse().unwrap_or(self.active_tab);
            pos.min(len - 1)
        };

        if target == self.active_tab {
            self.bus.info("tabmove");
            return;
        }

        let tab = self.tabs.remove(self.active_tab);
        self.tabs.insert(target, tab);
        self.active_tab = target;
        self.bus.info("tabmove");
    }

    /// `:tabs` — show an info popup listing all tabs with their active buffer
    /// name. The `>` marker indicates the active tab.
    pub(super) fn do_tabs(&mut self) {
        let mut lines: Vec<String> = Vec::new();
        for (i, tab) in self.tabs.iter().enumerate() {
            let label = format!("Tab page {}", i + 1);
            lines.push(label);
            // The name of the focused window's buffer.
            let name = if let Some(win) = self.windows[tab.focused_window].as_ref() {
                let slot = &self.slots[win.slot];
                slot.filename
                    .as_ref()
                    .and_then(|p| p.file_name())
                    .and_then(|n| n.to_str())
                    .map_or_else(|| "[No Name]".to_string(), |s| s.to_string())
            } else {
                "[No Name]".to_string()
            };
            let marker = if i == self.active_tab { '>' } else { ' ' };
            lines.push(format!("{marker} {name}"));
        }
        self.info_popup = Some(InfoPopup::new("tabs", lines.join("\n")));
    }

    // ─── Tab helpers ─────────────────────────────────────────────────────────

    /// `:tabnew [file]` / `:tabedit [file]` / `:tabe [file]`
    ///
    /// Open a new tab. With a file argument: load the file into a new slot.
    /// Without: open an empty unnamed buffer. The new tab gets its own layout
    /// and focused window; windows and slots are shared globally.
    pub(super) fn do_tabnew(&mut self, arg: &str) {
        use crate::app::window::{LayoutTree, Tab, Window};
        use hjkl_buffer::View;

        // Save current tab's viewport state before switching.
        self.sync_viewport_from_editor();

        // Determine the slot for the new tab.
        let new_slot_idx = if arg.is_empty() {
            // Empty scratch buffer. No editor built here (#151 Stage 2b —
            // see `do_vnew` / `build_slot`'s doc).
            let buffer_id = self.next_buffer_id;
            self.next_buffer_id += 1;
            let mut slot =
                super::BufferSlot::new(buffer_id, View::new(), hjkl_engine::Settings::default());
            slot.snapshot_saved();
            self.slots.push(slot);
            self.slots.len() - 1
        } else {
            match self.open_new_slot(std::path::PathBuf::from(arg)) {
                Ok(idx) => idx,
                Err(msg) => {
                    self.bus.info(msg);
                    return;
                }
            }
        };

        // Allocate a new window for the new tab.
        let new_win_id = self.next_window_id;
        self.next_window_id += 1;
        self.windows.push(Some(Window::new(new_slot_idx)));

        // Push the new tab and switch to it.
        self.tabs
            .push(Tab::new(LayoutTree::Leaf(new_win_id), new_win_id));
        self.active_tab = self.tabs.len() - 1;

        // Build the new window's editor (#151 Stage 2b — previously implicit:
        // `active_editor_mut()` fell back to the now-removed slot bridge
        // editor when `window_editors` had no entry yet; there is no such
        // fallback anymore, so this must run before `sync_viewport_to_editor`
        // below touches `active_editor_mut()`).
        self.reconcile_window_editors();

        // Sync viewport for the new tab's editor.
        self.sync_viewport_to_editor();
        self.bus.info("tabnew");
    }

    /// `:tabnext` / `:tabn` — cycle to the next tab (wraps).
    pub(super) fn do_tabnext(&mut self) {
        if self.tabs.len() <= 1 {
            self.bus.warn("only one tab");
            return;
        }
        let new_tab = (self.active_tab + 1) % self.tabs.len();
        self.switch_tab(new_tab);
        let n = self.active_tab + 1;
        let m = self.tabs.len();
        self.bus.info(format!("tab {n}/{m}"));
    }

    /// `:tabprev` / `:tabp` / `:tabN` — cycle to the previous tab (wraps).
    pub(super) fn do_tabprev(&mut self) {
        if self.tabs.len() <= 1 {
            self.bus.warn("only one tab");
            return;
        }
        let new_tab = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
        self.switch_tab(new_tab);
        let n = self.active_tab + 1;
        let m = self.tabs.len();
        self.bus.info(format!("tab {n}/{m}"));
    }

    /// `:tabclose` / `:tabc` — close the current tab.
    ///
    /// Refuses when only one tab remains (E444). On success, drops all windows
    /// that belonged exclusively to this tab and adjusts `active_tab`.
    pub(super) fn do_tabclose(&mut self) {
        if self.tabs.len() <= 1 {
            self.bus.error("E444: Cannot close last tab");
            return;
        }
        // Dispose the closing tab's docks first — the plain window drop below
        // would leak their scratch slots (#63 Phase 3).
        self.dispose_tab_docks(self.active_tab);
        // Collect window ids in the closing tab.
        let closing_leaves = self.tabs[self.active_tab].layout.leaves();
        // Drop those windows.
        for wid in closing_leaves {
            self.windows[wid] = None;
            self.window_folds.remove(&wid);
        }

        // Remove the tab.
        self.tabs.remove(self.active_tab);

        // Adjust active_tab: clamp to last if we were at the end.
        if self.active_tab >= self.tabs.len() {
            self.active_tab = self.tabs.len() - 1;
        }

        // Drop window_editors entries for the windows just closed above —
        // otherwise their dead ids keep pinning the old slots' rope Arcs.
        self.reconcile_window_editors();
        // Restore the new active tab's cursor/scroll into the editor.
        self.sync_viewport_to_editor();
        let n = self.active_tab + 1;
        let m = self.tabs.len();
        self.bus.info(format!("tab {n}/{m}"));
    }

    // ── LSP diagnostic navigation ─────────────────────────────────────────────

    /// `:lopen` — open a picker listing all LSP diagnostics for the active buffer.
    pub(crate) fn open_diag_picker(&mut self) {
        let diags = self.active().lsp_diags.clone();
        if diags.is_empty() {
            self.bus.warn("no diagnostics");
            return;
        }

        let entries: Vec<crate::picker_sources::DiagEntry> = diags
            .iter()
            .map(|d| {
                let src = d.source.as_deref().unwrap_or("");
                let code = d.code.as_deref().unwrap_or("");
                let annotation = match (src, code) {
                    ("", "") => String::new(),
                    (s, "") | ("", s) => format!(" ({s})"),
                    (s, c) => format!(" ({s}[{c}])"),
                };
                crate::picker_sources::DiagEntry {
                    label: format!(
                        "{}:{} [{}]{} {}",
                        d.start_row + 1,
                        d.start_col + 1,
                        sev_label(d.severity),
                        annotation,
                        d.message.lines().next().unwrap_or("")
                    ),
                    start_row: d.start_row,
                    start_col: d.start_col,
                }
            })
            .collect();

        let source = Box::new(crate::picker_sources::DiagSource::new(entries));
        self.picker = Some(crate::picker::Picker::new(source));
    }

    /// Jump to the next diagnostic (optionally filtered by minimum severity).
    /// `severity` = `Some(Error)` means skip non-Error diags.
    pub(crate) fn lnext_severity(&mut self, severity: Option<super::DiagSeverity>) {
        let (row, col) = self.active_editor().cursor();
        // Clone data we need before any mutable borrow.
        let candidates: Vec<super::LspDiag> = self
            .active()
            .lsp_diags
            .iter()
            .filter(|d| severity.is_none_or(|s| d.severity <= s))
            .cloned()
            .collect();

        if candidates.is_empty() {
            self.bus.warn("no diagnostics");
            return;
        }

        // Find the first diag after the current cursor position; wrap around.
        let target = candidates
            .iter()
            .find(|d| (d.start_row, d.start_col) > (row, col))
            .or_else(|| candidates.first())
            .cloned();

        if let Some(d) = target {
            self.active_editor_mut()
                .jump_cursor(d.start_row, d.start_col);
            self.active_editor_mut().ensure_cursor_in_scrolloff();
            self.sync_viewport_from_editor();
            let msg = d.message.lines().next().unwrap_or("").to_string();
            self.bus
                .info(format!("[{}] {}", sev_label(d.severity), msg));
        }
    }

    /// Jump to the previous diagnostic (optionally filtered).
    pub(crate) fn lprev_severity(&mut self, severity: Option<super::DiagSeverity>) {
        let (row, col) = self.active_editor().cursor();
        let candidates: Vec<super::LspDiag> = self
            .active()
            .lsp_diags
            .iter()
            .filter(|d| severity.is_none_or(|s| d.severity <= s))
            .cloned()
            .collect();

        if candidates.is_empty() {
            self.bus.warn("no diagnostics");
            return;
        }

        // Find the last diag before the current cursor position; wrap around.
        let target = candidates
            .iter()
            .rev()
            .find(|d| (d.start_row, d.start_col) < (row, col))
            .or_else(|| candidates.last())
            .cloned();

        if let Some(d) = target {
            self.active_editor_mut()
                .jump_cursor(d.start_row, d.start_col);
            self.active_editor_mut().ensure_cursor_in_scrolloff();
            self.sync_viewport_from_editor();
            let msg = d.message.lines().next().unwrap_or("").to_string();
            self.bus
                .info(format!("[{}] {}", sev_label(d.severity), msg));
        }
    }

    /// `:lfirst` — jump to the first diagnostic.
    pub(crate) fn ldiag_first(&mut self) {
        let target = self.active().lsp_diags.first().cloned();
        match target {
            None => {
                self.bus.warn("no diagnostics");
            }
            Some(d) => {
                self.active_editor_mut()
                    .jump_cursor(d.start_row, d.start_col);
                self.active_editor_mut().ensure_cursor_in_scrolloff();
                self.sync_viewport_from_editor();
                let msg = d.message.lines().next().unwrap_or("").to_string();
                self.bus
                    .info(format!("[{}] {}", sev_label(d.severity), msg));
            }
        }
    }

    /// `:llast` — jump to the last diagnostic.
    pub(crate) fn ldiag_last(&mut self) {
        let target = self.active().lsp_diags.last().cloned();
        match target {
            None => {
                self.bus.warn("no diagnostics");
            }
            Some(d) => {
                self.active_editor_mut()
                    .jump_cursor(d.start_row, d.start_col);
                self.active_editor_mut().ensure_cursor_in_scrolloff();
                self.sync_viewport_from_editor();
                let msg = d.message.lines().next().unwrap_or("").to_string();
                self.bus
                    .info(format!("[{}] {}", sev_label(d.severity), msg));
            }
        }
    }

    /// Whether the active buffer has any LSP diagnostic that touches `row`
    /// (its span covers the row). Used by the gutter context menu (#114 P6)
    /// to decide whether to surface diagnostic-specific entries.
    pub(crate) fn diagnostic_on_row(&self, row: usize) -> bool {
        self.active()
            .lsp_diags
            .iter()
            .any(|d| d.start_row <= row && row <= d.end_row)
    }

    /// `<leader>d` — show all diagnostics overlapping the cursor in info popup.
    pub(crate) fn show_diag_at_cursor(&mut self) {
        let (row, col) = self.active_editor().cursor();
        let diags = &self.active().lsp_diags;
        let hits: Vec<_> = diags
            .iter()
            .filter(|d| {
                // Overlaps cursor if cursor falls within [start, end).
                let after_start = (row, col) >= (d.start_row, d.start_col);
                let before_end = (row, col) < (d.end_row, d.end_col)
                    || (row == d.end_row && d.end_col == 0 && row == d.start_row);
                after_start && (before_end || row == d.start_row)
            })
            .collect();

        if hits.is_empty() {
            self.bus.warn("no diagnostics at cursor");
            return;
        }

        let text = hits
            .iter()
            .map(|d| format!("[{}] {}", sev_label(d.severity), d.message))
            .collect::<Vec<_>>()
            .join("\n---\n");
        self.info_popup = Some(InfoPopup::new("diagnostics", text));
    }

    /// `:LspInfo` — show running LSP servers + diagnostic info about the
    /// active buffer's attach state. Designed to surface the most common
    /// causes of "why isn't LSP working".
    pub(crate) fn show_lsp_info(&mut self) {
        let mut lines = Vec::new();

        // Top: enabled / disabled state.
        if self.lsp.is_none() {
            lines.push("LSP: disabled (set [lsp] enabled = true in config)".into());
            self.info_popup = Some(InfoPopup::new("lsp info", lines.join("\n")));
            return;
        }
        lines.push("LSP: enabled".into());

        // Active buffer diagnostic.
        let slot = self.active();
        match slot.filename.as_ref() {
            None => lines.push("Active buffer: [No Name] — no LSP attach possible".into()),
            Some(p) => {
                let lang = self.slot_language_id(self.focused_slot_idx());
                lines.push(format!("Active buffer: {}", p.display()));
                match lang {
                    None => lines.push("  → no language id mapped for this file".into()),
                    Some(lang_id) => {
                        let configured = self.config.lsp.servers.contains_key(lang_id.as_str());
                        lines.push(format!("  → language: {lang_id}"));
                        if !configured {
                            lines.push(format!(
                                "  → NO server configured for {lang_id} \
                                 (add [lsp.servers.{lang_id}] to your config.toml)"
                            ));
                        } else {
                            lines.push(format!(
                                "  → server configured: {}",
                                self.config.lsp.servers[lang_id.as_str()].command
                            ));
                        }
                    }
                }
            }
        }

        lines.push(String::new());

        // Configured servers in user config.
        lines.push("Configured servers:".into());
        if self.config.lsp.servers.is_empty() {
            lines.push("  (none — add [lsp.servers.<lang>] blocks)".into());
        } else {
            for (lang, cfg) in &self.config.lsp.servers {
                lines.push(format!("  {lang}: {}", cfg.command));
            }
        }

        lines.push(String::new());

        // Running servers.
        if self.lsp_state.is_empty() {
            lines.push("Running servers: (none)".into());
        } else {
            lines.push("Running servers:".into());
            for (i, (key, info)) in self.lsp_state.iter().enumerate() {
                let state = if info.initialized {
                    "initialized"
                } else {
                    "starting"
                };
                let caps = summarize_capabilities(&info.capabilities);
                lines.push(format!(
                    "  [{}] {} @ {}",
                    i + 1,
                    key.language,
                    key.root.display()
                ));
                lines.push(format!("      state: {state}"));
                if !caps.is_empty() {
                    lines.push(format!("      capabilities: {caps}"));
                }
            }
        }

        // Anvil section: show anvil status for each configured server's binary.
        lines.push(String::new());
        lines.push("Anvil tool status:".into());
        if let Some(registry) = self.anvil_registry.as_ref() {
            let mut found_any = false;
            for (lang, cfg) in &self.config.lsp.servers {
                let server_bin = &cfg.command;
                // Look up the binary name in the registry.
                if let Some(spec) = registry.get(server_bin) {
                    found_any = true;
                    match hjkl_anvil::store::read_rev(server_bin) {
                        Ok(Some(rev)) => {
                            lines.push(format!(
                                "  {lang} / {server_bin} (anvil: installed {})",
                                rev.version
                            ));
                        }
                        Ok(None) => {
                            lines.push(format!(
                                "  {lang} / {server_bin} (anvil: not installed, available {})",
                                spec.version
                            ));
                        }
                        Err(_) => {
                            lines.push(format!("  {lang} / {server_bin} (anvil: store error)"));
                        }
                    }
                } else {
                    lines.push(format!("  {lang} / {server_bin} (anvil: not in registry)"));
                }
            }
            if !found_any {
                lines.push("  (no LSP servers configured)".into());
            }
        } else {
            lines.push("  (registry not available)".into());
        }

        self.info_popup = Some(InfoPopup::new("lsp info", lines.join("\n")));
    }

    /// `:Anvil install <name>` — queue a background install job.
    pub(crate) fn anvil_install(&mut self, name: &str) {
        let Some(registry) = self.anvil_registry.as_ref() else {
            self.bus.error("anvil: registry not available");
            return;
        };
        let Some(spec) = registry.get(name) else {
            self.bus.error(format!("anvil: unknown tool `{name}`"));
            return;
        };
        let spec = spec.clone();
        let handle = self.anvil_pool.install(name.to_string(), spec);
        self.anvil_handles.insert(name.to_string(), handle);
        self.bus.info(format!("anvil: installing {name}\u{2026}"));
    }

    /// `:Anvil uninstall <name>` — remove the tool's package dir and bin symlink.
    pub(crate) fn anvil_uninstall(&mut self, name: &str) {
        let pkg_dir = hjkl_anvil::store::package_dir(name);
        let bin_dir = hjkl_anvil::store::bin_dir();
        match (pkg_dir, bin_dir) {
            (Ok(pkg), Ok(bin)) => {
                let _ = std::fs::remove_dir_all(&pkg);
                // Remove the bin symlink (name comes from spec.bin, not the tool key).
                if let Some(spec) = self.anvil_registry.as_ref().and_then(|r| r.get(name)) {
                    let link = bin.join(&spec.bin);
                    let _ = std::fs::remove_file(&link);
                }
                self.bus.info(format!("anvil: removed {name}"));
            }
            _ => {
                self.bus
                    .error(format!("anvil: failed to resolve paths for {name}"));
            }
        }
    }

    /// `:Anvil update <name>` — reinstall if the on-disk `.rev` doesn't match
    /// the registry's pinned version.
    pub(crate) fn anvil_update(&mut self, name: &str) {
        let Some(registry) = self.anvil_registry.as_ref() else {
            self.bus.error("anvil: registry not available");
            return;
        };
        let Some(spec) = registry.get(name) else {
            self.bus.error(format!("anvil: unknown tool `{name}`"));
            return;
        };
        let spec_version = spec.version.clone();
        let installed = hjkl_anvil::store::read_rev(name).ok().flatten();
        let needs_update = installed.is_none_or(|r| r.version != spec_version);
        if !needs_update {
            self.bus.info(format!("anvil: {name} already up to date"));
            return;
        }
        self.anvil_install(name);
    }

    /// `:Anvil update` (no args) — update every installed-but-outdated tool.
    pub(crate) fn anvil_update_all(&mut self) {
        let Some(registry) = self.anvil_registry.as_ref() else {
            self.bus.error("anvil: registry not available");
            return;
        };
        let names: Vec<String> = registry.names().map(String::from).collect();
        for name in &names {
            if let Ok(Some(_)) = hjkl_anvil::store::read_rev(name) {
                self.anvil_update(name);
            }
        }
        self.bus.info("anvil: update sweep started");
    }
}

/// Short severity label.
fn sev_label(s: super::DiagSeverity) -> &'static str {
    match s {
        super::DiagSeverity::Error => "E",
        super::DiagSeverity::Warning => "W",
        super::DiagSeverity::Info => "I",
        super::DiagSeverity::Hint => "H",
    }
}

/// Extract the `<cword>` / `<cWORD>` / `<cfile>` tokens under the active
/// buffer's cursor as owned strings. Each is `None` when the cursor sits on
/// whitespace (or off the relevant char class), matching vim's empty
/// expansion. The cursor column is a **char** index; the primitives index the
/// line string by chars, so no byte/char conversion is needed here.
pub fn cursor_word_tokens(app: &App) -> (Option<String>, Option<String>, Option<String>) {
    let (row, col) = app.active_editor().cursor();
    let line = hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), row);
    (
        hjkl_ex::word_under_cursor(&line, col),
        hjkl_ex::big_word_under_cursor(&line, col),
        hjkl_ex::filename_under_cursor(&line, col),
    )
}

/// Build an [`hjkl_ex::ExpandContext`] from current app state for Phase 7
/// filename expansion (`%`, `#`, `<cword>`, `<cWORD>`, `<cfile>`).
///
/// `cword` / `cwword` / `cfile` are sourced from the active buffer's cursor
/// via [`cursor_word_tokens`] (owned strings, so they don't extend any borrow
/// of `app`). Shared with the Tab-time inline expansion in prompt.rs. `pub`
/// only because clippy flags `pub(crate)` in this private module.
pub fn build_expand_context(app: &App) -> hjkl_ex::ExpandContext<'_> {
    let alt_path = app
        .prev_active
        .and_then(|i| app.slots.get(i))
        .and_then(|s| s.filename.as_deref());

    let (cword, cwword, cfile) = cursor_word_tokens(app);

    hjkl_ex::ExpandContext {
        current_path: app.active().filename.as_deref(),
        alt_path,
        cword: cword.map(std::borrow::Cow::Owned),
        cwword: cwword.map(std::borrow::Cow::Owned),
        cfile: cfile.map(std::borrow::Cow::Owned),
        cwd: None,
    }
}

/// Escape a literal path for interpolation into an ex command line
/// (`dispatch_ex`): unescaped `%` / `#` would otherwise be rewritten to the
/// current / alternate filename by `hjkl_ex::expand_args`, silently opening
/// the wrong file for names like `100%.txt`. Backslash-escaping makes
/// `expand_args` emit them verbatim.
pub fn escape_ex_path(s: &str) -> String {
    s.replace('%', "\\%").replace('#', "\\#")
}

/// Build a comma-separated list of capability names present in an LSP
/// server capabilities JSON object.
fn summarize_capabilities(caps: &serde_json::Value) -> String {
    let known = &[
        ("hoverProvider", "hover"),
        ("definitionProvider", "definition"),
        ("completionProvider", "completion"),
        ("referencesProvider", "references"),
        ("documentFormattingProvider", "formatting"),
        ("renameProvider", "rename"),
        ("codeActionProvider", "codeAction"),
        ("signatureHelpProvider", "signatureHelp"),
    ];
    let mut out = Vec::new();
    if let Some(obj) = caps.as_object() {
        for (key, label) in known {
            if obj
                .get(*key)
                .is_some_and(|v| v.as_bool().unwrap_or(true) && !v.is_null())
            {
                out.push(*label);
            }
        }
    }
    out.join(", ")
}

/// Format milliseconds-since-epoch delta as a human-readable relative time.
fn format_swap_age(write_time_unix_ms: u64) -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let now_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(write_time_unix_ms, |d| d.as_millis() as u64);
    let delta_secs = now_ms.saturating_sub(write_time_unix_ms) / 1000;
    if delta_secs < 60 {
        format!("{delta_secs}s ago")
    } else if delta_secs < 3600 {
        format!("{}m ago", delta_secs / 60)
    } else {
        format!("{}h ago", delta_secs / 3600)
    }
}
