//! Application state and event handling. `App::handle` is a pure-ish state
//! transition (no terminal IO) so the whole shell is unit-testable; rendering
//! reads the state in `ui::draw`. Per-screen state and handlers live in the
//! `status`, `log`, `diff`, `ci` (runs + graph), `ci_log`, and `pr`
//! submodules.

mod ci;
pub mod ci_log;
mod commands;
mod commit;
pub mod composer;
mod diff;
pub mod enrich;
mod expand;
pub mod file;
pub(crate) mod fuzzy;
mod log;
pub mod markdown;
mod mcp;
mod modal;
mod network;
pub mod pr;
pub mod pr_create;
mod search;
mod status;
pub mod text_edit;

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

pub use diff::{
    CommentLine, DeclaredRequest, DiffRow, DiffView, FileHighlights, FileScope, Pane, ScrollAlign,
    SplitRow, SplitSide, comment_display,
};
pub use log::LogView;
pub(crate) use status::{BRANCHES_TITLE, CI_TITLE, PRS_TITLE, RECENT_TITLE, UNPUSHED_TITLE};
pub use status::{Group, Row, Section, StatusView};

use crossterm::event::{KeyCode, KeyEvent};
use diffler_core::model::DiffModel;
use diffler_core::review::Review;
use diffler_core::source::ReviewSource;
use diffler_core::vcs::{BranchInfo, HeadInfo, NetworkOp, Vcs, VcsError};

use crate::config::{Config, KeyPress, LoadedConfig};
use crate::editor::EditorRequest;
use crate::event::AppEvent;
use crate::keymap::{self, Action, Context, Keymap, Resolved};
use crate::search::Search;
use crate::theme::Theme;
use crate::transient::{Transient, TransientKind, TransientResolve};

/// What the main loop should do after an event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flow {
    Continue,
    /// The event left the screen exactly as it was, so the loop can skip the
    /// draw. A terminal nobody is reading fills up otherwise, and the writes
    /// block the loop that answers the agent.
    Idle,
    Quit,
}

/// Screen stack entry. The per-screen state lives in `App::status`,
/// `App::log`, and `App::diff`; the stack only decides which one is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    Status,
    Log,
    Diff,
    /// The CI runs start page (list of recent runs for the repo's provider).
    Runs,
    /// The node-graph view of a CI run.
    Graph,
    /// The open pull requests of the repo's forge.
    Prs,
    /// A single job's log view.
    CiLog,
    /// One whole file, with or without its blame column.
    File,
}

impl Screen {
    fn context(self) -> Context {
        match self {
            Self::Status => Context::Status,
            // Runs is a plain list: it shares Log's motions
            Self::Runs | Self::Log => Context::Log,
            Self::CiLog => Context::CiLog,
            Self::Diff => Context::Diff,
            Self::Graph => Context::Graph,
            Self::Prs => Context::Prs,
            Self::File => Context::File,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    Info,
    Warning,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusMessage {
    pub text: String,
    pub severity: Severity,
}

/// Deferred operation a modal confirms, kept as data (not a closure) so
/// `App::handle` stays a pure state transition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingOp {
    Discard {
        path: String,
    },
    DeleteBranch(String),
    /// Remove one comment (and its forge copy when synced) after confirm.
    DeleteComment(String),
    /// Wipe every local comment of the active review.
    DeleteAllComments,
    /// Run a queued git op after the user confirms (set-upstream, force-push).
    RunGit {
        label: String,
        argv: Vec<String>,
    },
    /// Discard local commits: reset --hard to `upstream`, after a destructive
    /// confirm.
    ForcePull {
        upstream: String,
    },
}

/// What an input modal does with its buffer on submit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputOp {
    CreateBranch {
        checkout: bool,
    },
    /// One text field of the pull request being composed; the draft rides
    /// along so the form comes back with the rest of it intact.
    PrField {
        draft: Box<crate::app::pr_create::PrDraft>,
        field: crate::app::pr_create::PrField,
    },
    /// The optional top-level body of a PR review; empty submits without one.
    ReviewBody {
        number: u64,
        verdict: crate::ci::ReviewVerdict,
    },
}

/// What selecting a branch in the branch list does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchAction {
    Checkout,
    Delete,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Modal {
    Confirm {
        message: String,
        on_confirm: PendingOp,
    },
    Input {
        title: String,
        buffer: String,
        /// Character index into `buffer`.
        cursor: usize,
        on_submit: InputOp,
    },
    /// Branch picker feeding `action` with the selected name.
    BranchList {
        branches: Vec<BranchInfo>,
        list: fuzzy::FuzzyList,
        action: BranchAction,
    },
    /// Revision picker for a three-dot review: branches or log commits.
    RevList {
        title: &'static str,
        entries: Vec<RevChoice>,
        list: fuzzy::FuzzyList,
    },
    /// Fuzzy command palette over everything executable on this screen.
    Palette { list: fuzzy::FuzzyList },
    /// Fuzzy picker over the built-in themes; applies the pick live.
    Themes { list: fuzzy::FuzzyList },
    /// Remote picker feeding `purpose` with the selected remote name.
    RemoteList {
        remotes: Vec<String>,
        list: fuzzy::FuzzyList,
        purpose: RemotePurpose,
    },
    /// Reconcile choice when a pull finds the branch diverged from `upstream`.
    PullDiverged { upstream: String },
    /// Verdict picker for a PR review submit: approve, request changes, or
    /// comment only.
    ReviewVerdict {
        number: u64,
        /// What the submit will send, resolved when the dialog opens.
        summary: Vec<String>,
    },
    /// Compose a pull request for the checked-out branch.
    CreatePr {
        draft: Box<crate::app::pr_create::PrDraft>,
    },
    /// Base-branch picker for the create form. The draft rides along so the
    /// form comes back either way, with the choice or without it.
    PrBase {
        names: Vec<String>,
        list: fuzzy::FuzzyList,
        draft: Box<crate::app::pr_create::PrDraft>,
    },
    /// Fuzzy picker over every tracked file: the one way to a file the review
    /// does not touch.
    FilePicker {
        paths: Vec<String>,
        list: fuzzy::FuzzyList,
    },
    /// Keymap listing for the screen the popup opened over.
    Help,
}

impl Modal {
    /// The fuzzy list a dialog drives, for the pointer to move.
    pub(crate) fn list_mut(&mut self) -> Option<&mut fuzzy::FuzzyList> {
        match self {
            Self::BranchList { list, .. }
            | Self::PrBase { list, .. }
            | Self::RevList { list, .. }
            | Self::Palette { list }
            | Self::Themes { list }
            | Self::FilePicker { list, .. }
            | Self::RemoteList { list, .. } => Some(list),
            Self::Confirm { .. }
            | Self::Input { .. }
            | Self::PullDiverged { .. }
            | Self::ReviewVerdict { .. }
            | Self::CreatePr { .. }
            | Self::Help => None,
        }
    }
}

/// What choosing a remote in [`Modal::RemoteList`] does next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemotePurpose {
    SetUpstreamPush,
    Pull,
}

/// One row of the revision picker: what to diff against, and how it reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevChoice {
    pub rev: String,
    pub label: String,
}

/// A network git op the main loop runs by shelling out, with the terminal kept
/// up: it spawns a blocking task so the event loop keeps drawing, and the
/// result returns as [`AppEvent::GitDone`]. Set by a push/pull/fetch leaf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitOp {
    /// Human label for the status bar, e.g. "push".
    pub label: String,
    /// Full argv: program followed by its arguments.
    pub argv: Vec<String>,
}

struct Keymaps {
    status: Keymap,
    diff: Keymap,
    log: Keymap,
    ci_log: Keymap,
    graph: Keymap,
    prs: Keymap,
    file: Keymap,
}

impl Keymaps {
    /// Build the per-context keymaps, draining any binding warnings into `sink`.
    fn build(keys: &crate::config::KeysConfig, sink: &mut Vec<String>) -> Self {
        let mut build = |context| {
            let (keymap, warnings) = Keymap::for_context(context, keys);
            sink.extend(warnings);
            keymap
        };
        Self {
            status: build(Context::Status),
            diff: build(Context::Diff),
            log: build(Context::Log),
            ci_log: build(Context::CiLog),
            graph: build(Context::Graph),
            prs: build(Context::Prs),
            file: build(Context::File),
        }
    }
}

/// Built transients, applied with config overrides once at startup.
struct Transients {
    commit: Transient,
    branch: Transient,
    diff: Transient,
    log: Transient,
    push: Transient,
    pull: Transient,
    fetch: Transient,
    stash: Transient,
}

impl Transients {
    fn get(&self, kind: TransientKind) -> &Transient {
        match kind {
            TransientKind::Commit => &self.commit,
            TransientKind::Branch => &self.branch,
            TransientKind::Diff => &self.diff,
            TransientKind::Log => &self.log,
            TransientKind::Push => &self.push,
            TransientKind::Pull => &self.pull,
            TransientKind::Fetch => &self.fetch,
            TransientKind::Stash => &self.stash,
        }
    }
}

/// An open transient awaiting its next key. `opened_at` is a tick count so the
/// which-key reveal timer never reads a wall clock in render.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenTransient {
    pub kind: TransientKind,
    opened_at: u32,
}

/// Ticks (250ms each) the transient stays armed before the which-key panel is
/// revealed, so a fast resolving key never flashes the panel.
const WHICH_KEY_REVEAL_TICKS: u32 = 1;

/// Pending multi-key sequences die after this many 250ms ticks.
const PENDING_TIMEOUT_TICKS: u8 = 4;
/// How long the post-refresh `↻` status-bar indicator stays up.
const REFRESH_FLASH_TICKS: u8 = 4;
/// Poll interval (in 250ms ticks) when the watcher is missing or broken.
const FALLBACK_REFRESH_TICKS: u32 = 20;
/// How often (in 250ms ticks) the wall clock behind every rendered age moves.
const CLOCK_TICKS: u32 = 40;

/// How much the CI poll slows while the terminal is unfocused. Focus regained
/// polls at once, so the only cost of being wrong is a stale board nobody is
/// looking at.
const UNFOCUSED_POLL_FACTOR: u64 = 12;

/// What the main loop should fetch from the CI provider off-thread. Mirrors
/// `GitOp`/`pending_git`: set by the app, taken once by the loop, result
/// returned as an `AppEvent`.
#[derive(Debug, Clone)]
pub enum CiRequest {
    Runs,
    Pr,
    Prs,
    PrComments(u64),
    CreatePr(Box<crate::ci::NewPullRequest>),
    Detail(crate::ci::RunId),
    Extras(crate::ci::RunId),
    Log {
        run: crate::ci::RunId,
        job: crate::ci::JobId,
        offset: u64,
    },
}

fn build_transients(
    keys: &crate::config::KeysConfig,
    startup_warnings: &mut Vec<String>,
) -> Transients {
    let mut build = |kind| {
        let (transient, warnings) = Transient::build(kind, keys);
        startup_warnings.extend(warnings);
        transient
    };
    Transients {
        commit: build(TransientKind::Commit),
        branch: build(TransientKind::Branch),
        diff: build(TransientKind::Diff),
        log: build(TransientKind::Log),
        push: build(TransientKind::Push),
        pull: build(TransientKind::Pull),
        fetch: build(TransientKind::Fetch),
        stash: build(TransientKind::Stash),
    }
}

/// Lifecycle of the off-thread repo refresh: changes queue while a worker
/// runs, so bursts collapse into at most one follow-up run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RefreshState {
    #[default]
    Idle,
    Queued,
    Running,
    RunningQueued,
}

impl RefreshState {
    #[must_use]
    pub fn queue(self) -> Self {
        match self {
            Self::Idle | Self::Queued => Self::Queued,
            Self::Running | Self::RunningQueued => Self::RunningQueued,
        }
    }

    #[must_use]
    pub fn finish(self) -> Self {
        match self {
            Self::RunningQueued => Self::Queued,
            _ => Self::Idle,
        }
    }
}

/// Work held back until the queued refresh lands, for the few flows that read
/// repo state the refresh is about to move.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AfterRefresh {
    /// Send the pull request the finished push was clearing the way for.
    CreatePr,
}

/// A git remote diffler can pull CI from: its name, the detected forge, and the
/// remote URL the provider is built from.
#[derive(Debug, Clone)]
pub struct CiRemote {
    pub name: String,
    pub detected: crate::ci::Detected,
    pub url: Option<String>,
}

/// Detect a CI provider for every git remote (via the `Vcs` trait, not a
/// subprocess), `origin` first, deduped so two remotes on the same forge yield
/// one section. A forge whose CLI isn't installed degrades to no CI.
fn detect_ci_remotes(review: &Review, ci: &crate::config::CiConfig) -> Vec<CiRemote> {
    let mut names = review.vcs.remotes().unwrap_or_default();
    names.sort_by_key(|name| (name != "origin", name.clone()));
    let mut remotes: Vec<CiRemote> = Vec::new();
    for name in names {
        let url = review.vcs.remote_url(&name).ok().flatten();
        let Some(detected) = crate::ci::detect_for_repo(&review.repo_root, url.as_deref(), ci)
            .filter(crate::ci::provider_available)
        else {
            continue;
        };
        if remotes.iter().any(|r| r.detected == detected) {
            continue;
        }
        remotes.push(CiRemote {
            name,
            detected,
            url,
        });
    }
    remotes
}

/// Cursor step for half/full page motions. Before the first render the
/// viewport is unknown; a typical terminal height is a fine guess.
pub(crate) fn page_step(viewport: u16, full: bool) -> usize {
    let lines = if viewport == 0 {
        40
    } else {
        usize::from(viewport)
    };
    if full {
        lines.saturating_sub(1).max(1)
    } else {
        (lines / 2).max(1)
    }
}

pub struct App {
    pub review: Review,
    pub head: HeadInfo,
    pub theme: Theme,
    /// Whole-file syntax highlighter, pinned to `theme`'s syntax palette at
    /// construction. Shared (not rebuilt) with blocking-pool enrichment
    /// workers, which clone the `Arc`.
    pub highlighter: Arc<diffler_core::highlight::Highlighter>,
    pub config: Config,
    /// Author label stamped on comments and replies the human writes.
    pub author: String,
    pub screens: Vec<Screen>,
    pub status: StatusView,
    pub log: Option<LogView>,
    pub diff: Option<DiffView>,
    /// The embedded CI graph component, present while the Graph screen is up.
    pub graph: Option<crate::graph::GraphView>,
    /// CI remotes for the repo: one per distinct forge across all git remotes,
    /// computed at startup. Empty when no provider could be determined.
    pub(crate) ci_remotes: Vec<CiRemote>,
    /// Immutable commit/range diff models the MCP handlers serve, computed
    /// once per source (agent polls must not stall the render loop).
    source_models:
        std::collections::HashMap<String, std::sync::Arc<diffler_core::model::DiffModel>>,
    /// Off-thread refresh lifecycle: repo changes queue one worker at a time.
    pub refresh_state: RefreshState,
    /// Continuation the next landed refresh runs.
    pub(crate) after_refresh: Option<AfterRefresh>,
    /// Enrichment jobs waiting for a blocking-pool worker; drained by the
    /// main loop like the other pending slots.
    pub pending_enrich: Vec<enrich::EnrichJob>,
    /// Content hashes with a worker in flight, so bursts don't duplicate work.
    enrich_inflight: std::collections::HashSet<String>,
    /// Fetched reusable-workflow YAML shared across per-request provider
    /// rebuilds, so graph polls don't refetch immutable files.
    pub ci_yaml_cache: crate::ci::YamlCache,
    /// Conditional-request state per CI endpoint, so a poll that finds nothing
    /// changed costs no rate limit.
    pub ci_etags: crate::ci::EtagCache,
    /// Whether the terminal currently has focus. Terminals without focus
    /// reporting never say otherwise, so this stays true for them.
    pub focused: bool,
    /// Recent CI runs shown on the Runs screen.
    pub runs: Vec<crate::ci::CiRun>,
    /// The checked-out branch's PR, shown beside the runs section header.
    pub pr: Option<crate::ci::PullRequest>,
    /// Resolved `(merge_base, head_oid)` per opened PR, feeding its diff model.
    pub(crate) pr_ranges: std::collections::HashMap<u64, (String, String)>,
    /// A PR open waiting on its head fetch; retried when the fetch lands.
    pub(crate) pending_pr_open: Option<crate::ci::PullRequest>,
    /// A branch to switch to once its PR fetch lands.
    pub(crate) pending_pr_switch: Option<String>,
    pub prs: Vec<crate::ci::PullRequest>,
    pub prs_cursor: usize,
    /// Scroll offsets of the two full-screen lists, kept so the view holds
    /// still until the cursor reaches its margin.
    pub(crate) prs_scroll: usize,
    pub(crate) runs_scroll: usize,
    /// Outbound forge posts drained by the runtime each frame.
    pub pending_pr_posts: Vec<pr::PrPost>,
    pub(crate) pr_posts_inflight: std::collections::HashSet<String>,
    /// Whether the PR has been resolved for the current branch, so it's fetched
    /// once per branch instead of on every runs poll (reset on a repo change).
    pr_checked: bool,
    runs_cursor: usize,
    /// The run opened into the graph, re-polled for live status.
    open_run: Option<crate::ci::RunId>,
    /// The remote the open run came from, so its detail/log/extras route to the
    /// right forge (run ids aren't unique across forges).
    open_run_remote: Option<String>,
    /// The open run's artifacts + annotations, shown below the DAG.
    pub extras: Option<crate::ci::RunExtras>,
    /// The job whose log is on the `CiLog` screen.
    open_job: Option<crate::ci::JobId>,
    /// Accumulated raw job-log text and the byte offset the next poll resumes
    /// from. Parsed into [`ci_log`](Self::ci_log) for the foldable view.
    log_text: String,
    log_offset: u64,
    /// The opened job's step boundaries, used to bucket `log_text` into steps.
    log_steps: Vec<crate::ci::LogStepMeta>,
    /// The foldable step view over `log_text`, present while the `CiLog` screen is up.
    pub ci_log: Option<ci_log::CiLogView>,
    /// Set once a log chunk reports the job's log is complete, so polling stops
    /// (a dump-mode provider returns the whole log in one chunk).
    log_done: bool,
    /// A CI provider call the main loop should run off-thread (mirrors `pending_git`).
    pub pending_ci: Option<CiRequest>,
    /// The whole-file view, present while the `File` screen is up.
    pub file: Option<file::FileView>,
    /// A file whose content and blame the main loop should load off-thread.
    pub pending_file: Option<file::FileOpen>,
    /// Bumped per file request, so a load that outlives the user's interest is
    /// recognised and dropped when it lands.
    file_token: u64,
    /// Paths the main loop should ask git's attributes about, off-thread, for
    /// the kinds sidebar.
    pub pending_declared: Option<DeclaredRequest>,
    /// Bumped per declared-kinds request, so an answer for a file list the
    /// view has since replaced is dropped.
    declared_token: u64,
    pub modal: Option<Modal>,
    /// Where the open modal drew its rows, so a click finds the one under the
    /// pointer. Written by the renderer each frame.
    pub modal_hits: Option<crate::ui::popup::ListHits>,
    /// Active `/` search over the focused pane, if any. `search.open` means the
    /// prompt is capturing input; otherwise highlights persist while `n`/`N`
    /// navigate.
    pub search: Option<Search>,
    pub message: Option<StatusMessage>,
    /// Text to put on the system clipboard. The main loop, after the next
    /// draw, emits it as an OSC52 sequence (covers ssh/tmux) and also pipes it
    /// to the platform clipboard tool, then clears.
    pub pending_clipboard: Option<String>,
    /// Editor subprocess the main loop runs with the terminal suspended,
    /// then reports back through [`App::editor_finished`].
    pub pending_editor: Option<EditorRequest>,
    /// A pull request waiting on its branch to reach the forge.
    pub pending_pr_create: Option<Box<crate::ci::NewPullRequest>>,
    /// Network git op the main loop runs in the background (terminal stays up),
    /// reporting back through [`AppEvent::GitDone`]. Set by a push/pull/fetch
    /// transient leaf, taken once by the loop.
    pub pending_git: Option<GitOp>,
    /// Argv of the last push, so a rejection can offer a `--force-with-lease`
    /// retry against the same target.
    pub(crate) last_push_argv: Option<Vec<String>>,
    /// Watcher health flag, set by `watch::spawn_watcher`. `None` (no
    /// watcher) counts as unhealthy: the tick fallback polls instead.
    pub watcher_healthy: Option<Arc<AtomicBool>>,
    /// Ticks left on the status-bar `↻` indicator after a repo change.
    pub refresh_flash: u8,
    /// Feedback epoch counter, bumped when the human sends feedback (`Z`)
    /// or touches a comment; the MCP `wait_for_feedback` long-poll holds
    /// receivers on it.
    pub feedback_tx: tokio::sync::watch::Sender<u64>,
    /// Bound port of the embedded MCP server, if it started successfully.
    pub mcp_port: Option<u16>,
    keymaps: Keymaps,
    transients: Transients,
    /// The open transient, if any. Set when a top-level prefix fires; cleared
    /// when a key resolves, Esc aborts, or an unknown key closes it.
    pub transient: Option<OpenTransient>,
    pending: Vec<KeyPress>,
    pending_ticks: u8,
    tick_count: u32,
    /// Time and cell of the last left-press, for double-click detection.
    last_click: Option<(std::time::Instant, u16, u16)>,
    /// Wall-clock seconds, refreshed with the commit list, for rendering
    /// commit ages ("3h ago"). A field so tests can pin it.
    pub now_unix: i64,
}

/// Current wall-clock time in unix seconds, or 0 if the clock is before the
/// epoch (it never is).
pub(crate) fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .ok()
        .and_then(|d| i64::try_from(d.as_secs()).ok())
        .unwrap_or(0)
}

impl App {
    // a flat constructor: one init line per field of owned state
    #[allow(clippy::too_many_lines)]
    pub fn new(review: Review, loaded: LoadedConfig) -> Self {
        let LoadedConfig {
            config,
            warnings: mut startup_warnings,
            ..
        } = loaded;
        let (theme, theme_warning) = Theme::from_name(&config.ui.theme);
        startup_warnings.extend(theme_warning);
        let highlighter = Arc::new(diffler_core::highlight::Highlighter::new(theme.syntax));
        let keymaps = Keymaps::build(&config.keys, &mut startup_warnings);
        let transients = build_transients(&config.keys, &mut startup_warnings);

        let mut message = startup_warnings
            .into_iter()
            .next()
            .map(|text| StatusMessage {
                text,
                severity: Severity::Warning,
            });
        let head = match review.vcs.head() {
            Ok(head) => head,
            Err(err) => {
                message = Some(StatusMessage {
                    text: err.to_string(),
                    severity: Severity::Error,
                });
                empty_head()
            }
        };
        let (unpushed, recent) = match status::load_commit_lists(
            review.vcs.as_ref(),
            config.ui.recent_commits,
            status::UNPUSHED_LIMIT,
        ) {
            Ok(lists) => lists,
            Err(err) => {
                message = Some(StatusMessage {
                    text: err.to_string(),
                    severity: Severity::Error,
                });
                (None, Vec::new())
            }
        };
        let branches = match status::load_branches(review.vcs.as_ref()) {
            Ok(branches) => branches,
            Err(err) => {
                message = Some(StatusMessage {
                    text: err.to_string(),
                    severity: Severity::Error,
                });
                Vec::new()
            }
        };

        let ci_remotes = detect_ci_remotes(&review, &config.ci);

        let mut app = Self {
            review,
            head,
            theme,
            highlighter,
            config,
            // git config user.name is not exposed through HeadInfo; $USER is
            // a good-enough human label for feedback exports
            author: std::env::var("USER").unwrap_or_else(|_| "you".to_owned()),
            screens: vec![Screen::Status],
            status: StatusView::new(unpushed, recent, branches),
            log: None,
            diff: None,
            graph: None,
            // kick an initial CI fetch so the Status section populates at launch
            // (evaluated before `ci_remotes` is moved into the struct below)
            pending_ci: (!ci_remotes.is_empty()).then_some(CiRequest::Runs),
            ci_remotes,
            source_models: std::collections::HashMap::new(),
            refresh_state: RefreshState::Idle,
            after_refresh: None,
            pending_enrich: Vec::new(),
            enrich_inflight: std::collections::HashSet::new(),
            ci_yaml_cache: crate::ci::YamlCache::default(),
            ci_etags: crate::ci::EtagCache::default(),
            focused: true,
            runs: Vec::new(),
            pr: None,
            pr_checked: false,
            pr_ranges: std::collections::HashMap::new(),
            pending_pr_open: None,
            pending_pr_switch: None,
            prs: Vec::new(),
            prs_cursor: 0,
            pending_pr_posts: Vec::new(),
            pr_posts_inflight: std::collections::HashSet::new(),
            runs_cursor: 0,
            prs_scroll: 0,
            runs_scroll: 0,
            open_run: None,
            open_run_remote: None,
            extras: None,
            open_job: None,
            log_text: String::new(),
            log_offset: 0,
            log_steps: Vec::new(),
            ci_log: None,
            log_done: false,
            modal: None,
            modal_hits: None,
            search: None,
            message,
            file: None,
            pending_file: None,
            pending_declared: None,
            declared_token: 0,
            file_token: 0,
            pending_clipboard: None,
            pending_editor: None,
            pending_pr_create: None,
            pending_git: None,
            last_push_argv: None,
            watcher_healthy: None,
            refresh_flash: 0,
            feedback_tx: tokio::sync::watch::Sender::new(0),
            mcp_port: None,
            keymaps,
            transients,
            transient: None,
            pending: Vec::new(),
            pending_ticks: 0,
            tick_count: 0,
            last_click: None,
            now_unix: now_unix(),
        };
        // the default cursor (0) can start on the repo-band divider when the
        // branch band above it is empty; settle it on real content
        app.clamp_cursor();
        app
    }

    /// The screen under the cursor; the stack is never empty because `Back`
    /// on the last screen quits instead of popping.
    pub fn screen(&self) -> Screen {
        self.screens.last().copied().unwrap_or(Screen::Status)
    }

    /// Index of the selected run on the Runs screen.
    pub fn runs_selected(&self) -> usize {
        self.runs_cursor
    }

    /// The accumulated job-log text on the `CiLog` screen.
    pub fn log_text(&self) -> &str {
        &self.log_text
    }

    /// The foldable step view over the `CiLog` screen, once a log chunk arrived.
    pub fn ci_log(&self) -> Option<&ci_log::CiLogView> {
        self.ci_log.as_ref()
    }

    /// The CI remotes for the repo (the main loop builds a provider per remote
    /// to service a `pending_ci` request).
    pub fn ci_remotes(&self) -> Vec<CiRemote> {
        self.ci_remotes.clone()
    }

    /// The job the `CiLog` screen is showing, for its header line.
    pub fn open_job_name(&self) -> Option<String> {
        self.open_job.as_ref().map(|job| job.0.clone())
    }

    /// The run the Graph screen is showing, for its header line.
    pub fn open_run_summary(&self) -> Option<&crate::ci::CiRun> {
        let id = self.open_run.as_ref()?;
        self.runs
            .iter()
            .find(|run| &run.id == id && run.remote == self.open_run_remote)
    }

    /// The CI remote the open run came from (for routing its detail/log/extras),
    /// or the primary remote when the run isn't tagged or none is open.
    pub fn ci_remote_for_open_run(&self) -> Option<CiRemote> {
        match &self.open_run_remote {
            Some(name) => self.ci_remotes.iter().find(|r| &r.name == name).cloned(),
            None => self.ci_remotes.first().cloned(),
        }
    }

    /// Keymap of the active screen, with config remaps applied: what the
    /// hint lines and the help popup render from.
    pub fn active_keymap(&self) -> &Keymap {
        match self.screen().context() {
            Context::Status => &self.keymaps.status,
            Context::Diff => &self.keymaps.diff,
            Context::Log => &self.keymaps.log,
            Context::CiLog => &self.keymaps.ci_log,
            Context::Graph => &self.keymaps.graph,
            Context::Prs => &self.keymaps.prs,
            Context::File => &self.keymaps.file,
        }
    }

    /// Whether the path carries a current viewed mark, judged against the
    /// review diff (the model viewed hashes are reconciled with).
    pub fn is_path_viewed(&self, path: &str) -> bool {
        self.review
            .model()
            .files
            .iter()
            .find(|f| f.path == path)
            .is_some_and(|f| self.review.session.is_viewed(path, &f.content_hash()))
    }

    /// `(files in the shown diff, files marked viewed)` for the status bar,
    /// against the open diff's source (working tree when none is open).
    pub fn viewed_counts(&self) -> (usize, usize) {
        let source = self.active_review_source();
        let model = self
            .diff
            .as_ref()
            .and_then(|diff| diff.commit_model.as_ref())
            .unwrap_or_else(|| self.review.model());
        let session = self.review.session_for(&source);
        let total = model.files.len();
        let viewed = model
            .files
            .iter()
            .filter(|f| session.is_viewed(&f.path, &f.content_hash()))
            .count();
        (total, viewed)
    }

    #[allow(clippy::too_many_lines)] // one arm per event; a flat match reads best
    pub fn handle(&mut self, event: AppEvent) -> Flow {
        match event {
            AppEvent::Quit => Flow::Quit,
            AppEvent::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => {
                if self.modal.is_some() {
                    self.handle_modal_key(&key)
                } else if self.composer_open() {
                    self.handle_composer_key(&key)
                } else if self.transient.is_some() {
                    self.handle_transient_key(&key)
                } else if self.search.as_ref().is_some_and(|s| s.open) {
                    self.handle_search_key(&key)
                } else if key.code == KeyCode::Esc && self.search.is_some() {
                    self.search = None;
                    Flow::Continue
                } else {
                    self.handle_key(&key)
                }
            }
            AppEvent::Focus(focused) => {
                self.focused = focused;
                // catch up the moment someone looks again, so coming back
                // never shows a stale board
                if focused {
                    self.queue_ci_poll();
                }
                Flow::Continue
            }
            AppEvent::Tick => self.on_tick(),
            AppEvent::RefreshDone(result) => {
                self.on_refresh_done(*result);
                Flow::Continue
            }
            AppEvent::Enriched(outcome) => {
                self.on_enriched(*outcome);
                Flow::Continue
            }
            AppEvent::FileLoaded {
                result,
                line,
                token,
            } => self.on_file_loaded(*result, line, token),
            AppEvent::DeclaredKinds { kinds, token } => self.on_declared_kinds(kinds, token),
            AppEvent::CiRuns(runs) => {
                self.on_ci_runs(runs);
                Flow::Continue
            }
            AppEvent::CiPr(pr) => self.on_pr_event(pr),
            AppEvent::PrCreated(result) => {
                self.on_pr_created(*result);
                Flow::Continue
            }
            AppEvent::CiPrs(prs) => self.on_prs_event(prs),
            AppEvent::PrComments {
                number,
                comments,
                pr,
            } => self.on_pr_comments_event(number, &comments, pr),
            AppEvent::PrPosted { post, result } => self.on_pr_posted_event(&post, result),
            AppEvent::CiRunDetail(detail) => {
                self.on_run_detail(&detail);
                Flow::Continue
            }
            AppEvent::CiExtras(extras) => self.on_ci_extras(extras),
            AppEvent::CiLog {
                text,
                steps,
                next_offset,
                done,
            } => {
                self.on_ci_log(&text, steps, next_offset, done);
                Flow::Continue
            }
            AppEvent::CiError(message) => self.on_ci_error(message),
            AppEvent::CiPrsError(message) => self.on_ci_prs_error(message),
            AppEvent::RepoChanged => {
                self.queue_refresh();
                self.refresh_flash = REFRESH_FLASH_TICKS;
                // a checkout may have changed the branch; re-resolve its PR
                self.pr_checked = false;
                Flow::Continue
            }
            AppEvent::Mcp(request) => {
                // a closed reply channel means the agent already gave up
                // (e.g. timed out while an editor suspended the loop);
                // acting on it would replay a stale mutation unseen
                if request.reply.is_closed() {
                    self.info("dropped stale agent request");
                } else {
                    let response = self.handle_mcp(request.kind);
                    // a dropped receiver means the agent gave up mid-call
                    let _ = request.reply.send(response);
                }
                Flow::Continue
            }
            AppEvent::GitDone { label, ok, output } => {
                self.git_finished(&label, ok, &output);
                Flow::Continue
            }
            // a dialog with rows takes the pointer for itself; a transient or
            // the composer still owns input while open
            AppEvent::Mouse(mouse) if self.modal.is_some() => {
                self.handle_modal_mouse(mouse);
                Flow::Continue
            }
            AppEvent::Mouse(mouse) if self.transient.is_none() && !self.composer_open() => {
                self.handle_mouse(mouse);
                Flow::Continue
            }
            AppEvent::Key(_) | AppEvent::Mouse(_) | AppEvent::Resize => Flow::Continue,
        }
    }

    /// Translate a raw mouse event into a [`MouseGesture`] and dispatch it to
    /// the active screen. Each screen implements `*_mouse(MouseGesture)` and
    /// must handle every variant, so the `match self.screen()` here is the one
    /// place that forces a new screen to wire up mouse support (it won't
    /// compile without an arm), and the exhaustive gesture match in each
    /// handler forces every interaction to be considered.
    fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
        use crossterm::event::{MouseButton, MouseEventKind};
        // the graph view consumes raw mouse events itself
        if self.screen() == Screen::Graph {
            if let Some(action) = self.graph.as_mut().and_then(|g| g.on_mouse(mouse)) {
                self.on_graph_action(&action);
            }
            return;
        }
        let (col, row) = (mouse.column, mouse.row);
        let gesture = match mouse.kind {
            MouseEventKind::ScrollDown => MouseGesture::Scroll {
                col,
                row,
                down: true,
            },
            MouseEventKind::ScrollUp => MouseGesture::Scroll {
                col,
                row,
                down: false,
            },
            MouseEventKind::Down(MouseButton::Left) => {
                if self.register_click_is_double(col, row) {
                    MouseGesture::DoublePress { col, row }
                } else {
                    MouseGesture::Press { col, row }
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => MouseGesture::Drag { col, row },
            MouseEventKind::Down(MouseButton::Right) => MouseGesture::Cancel,
            _ => return,
        };
        match self.screen() {
            Screen::Status => self.status_mouse(gesture),
            Screen::Diff => self.diff_mouse(gesture),
            Screen::Log => self.log_mouse(gesture),
            Screen::CiLog => self.ci_log_mouse(gesture),
            // the Runs/Graph/File screens are keyboard-driven
            Screen::Graph | Screen::Runs | Screen::Prs | Screen::File => {}
        }
    }

    /// Mouse on the logs screen: wheel scrolls the cursor, click positions it,
    /// double-click folds the step, drag extends a selection, mirroring the
    /// diff/log screens so the foldable log behaves the same under the pointer.
    fn register_click_is_double(&mut self, col: u16, row: u16) -> bool {
        let now = std::time::Instant::now();
        let double = self.last_click.is_some_and(|(at, c, r)| {
            now.duration_since(at) < DOUBLE_CLICK_WINDOW && c.abs_diff(col) <= 1 && r == row
        });
        self.last_click = if double { None } else { Some((now, col, row)) };
        double
    }

    fn handle_key(&mut self, key: &KeyEvent) -> Flow {
        // Esc leaves visual selection; it stays out of the keymap because it
        // also drains pending chords and cancels modals everywhere else
        if key.code == KeyCode::Esc && self.visual_active() {
            match self.screen() {
                Screen::Diff => {
                    if let Some(diff) = self.diff.as_mut() {
                        diff.visual_anchor = None;
                    }
                }
                Screen::Log => {
                    if let Some(log) = self.log.as_mut() {
                        log.visual_anchor = None;
                    }
                }
                Screen::CiLog => {
                    if let Some(view) = self.ci_log.as_mut() {
                        view.visual_anchor = None;
                    }
                }
                Screen::Status | Screen::Graph | Screen::Runs | Screen::Prs | Screen::File => {}
            }
            self.pending.clear();
            return Flow::Continue;
        }
        self.pending_ticks = 0;
        let press = keymap::press_from_event(key);
        let mut pending = std::mem::take(&mut self.pending);
        let resolved = self.active_keymap().resolve(&mut pending, press);
        self.pending = pending;
        match resolved {
            Resolved::Action(action) => self.dispatch(action),
            Resolved::Transient(kind) => {
                self.open_transient(kind);
                Flow::Continue
            }
            Resolved::Pending | Resolved::Unbound => Flow::Continue,
        }
    }

    fn open_transient(&mut self, kind: TransientKind) {
        self.message = None;
        self.transient = Some(OpenTransient {
            kind,
            opened_at: self.tick_count,
        });
    }

    /// While a transient is armed it owns the keyboard: Esc/Backspace close it,
    /// a leaf key fires and closes, an unknown key closes with a beep
    /// (neogit-style).
    fn handle_transient_key(&mut self, key: &KeyEvent) -> Flow {
        let Some(open) = self.transient else {
            return Flow::Continue;
        };
        // a single-level transient has nothing to pop, so Backspace and Esc
        // both abort it without dispatching
        if matches!(key.code, KeyCode::Esc | KeyCode::Backspace) {
            self.transient = None;
            return Flow::Continue;
        }
        let press = keymap::press_from_event(key);
        let resolved = self.transients.get(open.kind).resolve(&press);
        self.transient = None;
        match resolved {
            TransientResolve::Action(action) => self.dispatch(action),
            TransientResolve::Unbound => {
                // neogit beeps and closes on an unbound key in a transient
                self.info("no such command");
                Flow::Continue
            }
        }
    }

    /// The built transient for `kind`, with config overrides applied: what
    /// the help popup reads.
    pub fn transient(&self, kind: TransientKind) -> &Transient {
        self.transients.get(kind)
    }

    /// The transient panel to reveal: `Some` once the reveal timer has elapsed
    /// since the transient opened, so a fast resolving key never flashes it.
    pub fn which_key_panel(&self) -> Option<&Transient> {
        let open = self.transient?;
        if self.tick_count.wrapping_sub(open.opened_at) >= WHICH_KEY_REVEAL_TICKS {
            Some(self.transients.get(open.kind))
        } else {
            None
        }
    }

    fn visual_active(&self) -> bool {
        match self.screen() {
            Screen::Diff => self
                .diff
                .as_ref()
                .is_some_and(|d| d.visual_anchor.is_some()),
            Screen::Log => self.log.as_ref().is_some_and(|l| l.visual_anchor.is_some()),
            Screen::CiLog => self
                .ci_log
                .as_ref()
                .is_some_and(|v| v.visual_anchor.is_some()),
            Screen::Status | Screen::Graph | Screen::Runs | Screen::Prs | Screen::File => false,
        }
    }

    /// While a modal is up it owns the keyboard.
    pub fn feedback_epoch(&self) -> u64 {
        *self.feedback_tx.borrow()
    }

    /// The review source the user is currently looking at: the open diff's
    /// source, or the working tree on the status screen.
    pub(crate) fn active_review_source(&self) -> ReviewSource {
        self.diff
            .as_ref()
            .map_or(ReviewSource::WorkingTree, |diff| diff.source.clone())
    }

    /// Persist the session and invalidate comment-bearing rows after a
    /// human comment change; also wakes agents waiting for feedback. Agent
    /// mutations (see the `mcp` module) call [`App::persist_review_change`]
    /// directly instead, so their own change never wakes their own
    /// `wait_for_feedback` poll.
    fn after_session_change(&mut self) {
        self.feedback_tx.send_modify(|epoch| *epoch += 1);
        let source = self.active_review_source();
        self.persist_review_change(&source);
    }

    /// Persist `source`'s session and invalidate the open diff's cached rows
    /// so a stale comment/viewed-mark render never survives the mutation.
    pub(crate) fn persist_review_change(&mut self, source: &ReviewSource) {
        if let Err(err) = self.review.save_for(source) {
            self.error(err.to_string());
        }
        if let Some(diff) = self.diff.as_mut() {
            diff.invalidate();
        }
    }

    /// One 250ms beat: age the timed UI state and re-poll what the screen is
    /// watching. `Idle` when the beat left the screen untouched, so the loop
    /// can skip a draw nobody would see.
    /// Whether the open screen renders a time against `now_unix`. The diff,
    /// graph, logs and PR list carry no ages, so the clock moving under them
    /// changes nothing to look at.
    fn screen_shows_ages(&self) -> bool {
        matches!(self.screen(), Screen::Status | Screen::Log | Screen::Runs)
    }

    fn on_tick(&mut self) -> Flow {
        let mut changed = self.expire_pending();
        changed |= self.refresh_flash > 0;
        let which_key = self.which_key_panel().is_some();
        self.refresh_flash = self.refresh_flash.saturating_sub(1);
        self.tick_count = self.tick_count.wrapping_add(1);
        // the which-key panel reveals on a tick count, so the tick that crosses
        // its delay is the frame that shows it
        changed |= self.which_key_panel().is_some() != which_key;
        if self.tick_count.is_multiple_of(FALLBACK_REFRESH_TICKS) && self.watcher_unhealthy() {
            self.queue_refresh();
        }
        // every age on screen is measured against this clock, so a stopped one
        // reads "0s" for as long as nothing else refreshes. A coarse step keeps
        // them honest without repainting every second, and only a screen that
        // shows an age is worth the repaint.
        if self.tick_count.is_multiple_of(CLOCK_TICKS) {
            let now = now_unix();
            changed |= now != self.now_unix && self.screen_shows_ages();
            self.now_unix = now;
        }
        // re-poll the active CI screen on a relaxed cadence (250ms ticks);
        // saturating + clamp so a pathological config can't zero or overflow it.
        // Nobody watching means nobody to show it to, so an unfocused terminal
        // drops to a heartbeat and catches up on the focus event.
        let seconds = if self.focused {
            self.config.ci.poll_seconds.max(1)
        } else {
            self.config.ci.poll_seconds.max(1) * UNFOCUSED_POLL_FACTOR
        };
        let poll_ticks = u32::try_from(seconds.saturating_mul(4)).unwrap_or(u32::MAX);
        // the poll itself paints nothing; its answer arrives as an event
        if self.tick_count.is_multiple_of(poll_ticks) {
            self.queue_ci_poll();
        }
        if changed { Flow::Continue } else { Flow::Idle }
    }

    /// True when the wait dropped the half-typed chord, so the caller can tell
    /// the keymap state moved on.
    fn expire_pending(&mut self) -> bool {
        if self.pending.is_empty() {
            return false;
        }
        self.pending_ticks += 1;
        if self.pending_ticks < PENDING_TIMEOUT_TICKS {
            return false;
        }
        self.pending.clear();
        self.pending_ticks = 0;
        true
    }

    fn dispatch(&mut self, action: Action) -> Flow {
        self.message = None;
        match action {
            Action::Quit => return Flow::Quit,
            Action::Back => return self.pop_screen(),
            Action::Refresh => self.queue_refresh(),
            Action::Help => self.modal = Some(Modal::Help),
            Action::Palette => {
                let (_, haystack) = self.command_index_haystack();
                let mut list = fuzzy::FuzzyList::typing();
                list.rerank(&haystack);
                self.modal = Some(Modal::Palette { list });
            }
            Action::SendFeedback => {
                self.feedback_tx.send_modify(|epoch| *epoch += 1);
                self.info("feedback sent to waiting agents");
            }
            Action::Search => self.search_start(),
            Action::SearchNext => self.search_step_or_follow(true),
            Action::SearchPrev => self.search_step_or_follow(false),
            Action::OpenRuns => self.open_runs(),
            Action::OpenPrs => self.open_prs(),
            Action::CreatePr => self.create_pr_start(),
            Action::CommentsOverview => self.toggle_comments_sidebar(),
            Action::SwitchTheme => self.open_theme_picker(),
            Action::OpenFilePicker => self.open_file_picker(),
            Action::Blame => self.blame_focused(),
            action => match self.screen() {
                Screen::Status => self.dispatch_status(action),
                Screen::Log => self.dispatch_log(action),
                Screen::Diff => self.dispatch_diff(action),
                Screen::CiLog => self.dispatch_ci_log(action),
                Screen::Runs => self.dispatch_runs(action),
                Screen::Graph => self.dispatch_graph(action),
                Screen::Prs => self.dispatch_prs(action),
                Screen::File => self.dispatch_file(action),
            },
        }
        Flow::Continue
    }

    /// Open the CI runs start page for the repo's detected provider.
    /// Enter a screen. Clears any search, whose matches are keyed to the
    /// leaving screen's rows.
    pub(crate) fn push_screen(&mut self, screen: Screen) {
        self.search = None;
        self.screens.push(screen);
    }

    fn pop_screen(&mut self) -> Flow {
        if self.screens.len() <= 1 {
            return Flow::Quit;
        }
        self.search = None;
        // leaving a screen abandons the file it asked for, so a slow load
        // cannot arrive later and push the file view over what replaced it
        self.cancel_file_load();
        match self.screens.pop() {
            Some(Screen::Diff) => self.diff = None,
            Some(Screen::Log) => self.log = None,
            Some(Screen::File) => self.file = None,
            Some(Screen::Graph) => {
                self.graph = None;
                self.open_run = None;
                self.open_run_remote = None;
                self.extras = None;
            }
            Some(Screen::CiLog) => {
                self.open_job = None;
                self.log_text.clear();
                self.ci_log = None;
            }
            Some(Screen::Runs) => self.runs_cursor = 0,
            Some(Screen::Prs) => self.prs_cursor = 0,
            Some(Screen::Status) | None => {}
        }
        Flow::Continue
    }

    fn open_theme_picker(&mut self) {
        let mut list = fuzzy::FuzzyList::default();
        list.rerank(&crate::theme::names());
        self.modal = Some(Modal::Themes { list });
    }

    /// Swap the active theme live: re-pin the syntax highlighter and drop the
    /// cached highlights so the visible files re-enrich in the new palette.
    pub(crate) fn apply_theme(&mut self, name: &str) {
        let (theme, _) = Theme::from_name(name);
        self.highlighter = Arc::new(diffler_core::highlight::Highlighter::new(theme.syntax));
        self.theme = theme;
        if let Some(diff) = self.diff.as_mut() {
            diff.highlights.clear();
            diff.clear_enriched();
            diff.invalidate();
        }
        self.queue_enrich_selected();
        self.info(format!("theme: {name}"));
    }

    pub fn info(&mut self, text: impl Into<String>) {
        self.message = Some(StatusMessage {
            text: text.into(),
            severity: Severity::Info,
        });
    }

    /// Put a resolved value on the clipboard, or name what had none. Only a
    /// pull request can come back empty; a commit always has its sha.
    pub(crate) fn copy_or_report(&mut self, value: Option<String>, missing: &str) {
        let Some(value) = value else {
            self.info(format!("no URL for {missing}"));
            return;
        };
        self.info(format!("copied {value}"));
        self.pending_clipboard = Some(value);
    }

    pub fn error(&mut self, text: impl Into<String>) {
        self.message = Some(StatusMessage {
            text: text.into(),
            severity: Severity::Error,
        });
    }

    /// Run a VCS mutation, then queue a refresh so the sections catch up with
    /// reality.
    pub(crate) fn vcs_op(&mut self, op: impl FnOnce(&dyn Vcs) -> Result<(), VcsError>) {
        match op(self.review.vcs.as_ref()) {
            Ok(()) => self.queue_refresh(),
            Err(err) => self.error(err.to_string()),
        }
    }

    /// Ask the runtime for a repo refresh. It runs on the blocking pool and
    /// lands as [`AppEvent::RefreshDone`], so nothing after this call sees the
    /// new state; work that needs it goes in `after_refresh`.
    pub(crate) fn queue_refresh(&mut self) {
        self.refresh_state = self.refresh_state.queue();
    }

    /// Run the queued refresh inline, mirroring `dispatch_refresh` in the
    /// runtime down to the guard: with nothing queued there is nothing to
    /// settle, so a test that expects state to move has to have asked for it.
    #[cfg(test)]
    pub(crate) fn settle_refresh(&mut self) {
        if self.refresh_state != RefreshState::Queued {
            return;
        }
        self.refresh_state = RefreshState::Running;
        let against = self.against_rev().map(str::to_owned);
        let result = Review::compute_refresh(
            &self.review.repo_root,
            self.config.ui.context_lines,
            against.as_deref(),
        )
        .map_err(|err| err.to_string());
        self.on_refresh_done(result);
    }

    fn on_refresh_done(&mut self, result: Result<diffler_core::review::Refreshed, String>) {
        self.refresh_state = self.refresh_state.finish();
        match result {
            Ok(refreshed) => self.apply_refresh(refreshed),
            Err(message) => self.error(message),
        }
        if let Some(AfterRefresh::CreatePr) = self.after_refresh.take() {
            self.pr_create_after_push();
        }
    }

    /// Install a computed refresh.
    pub(crate) fn apply_refresh(&mut self, refreshed: diffler_core::review::Refreshed) {
        let diffler_core::review::Refreshed {
            status,
            model,
            against,
        } = refreshed;
        self.now_unix = now_unix();
        let status_anchor = self.status_cursor_anchor();
        let diff_anchor_path = self.diff_cursor_path();
        // a no-op refresh (poll tick, watcher echo) keeps the old model: the
        // rebuild carries no emphasis, so swapping it in would force the whole
        // enrichment pipeline to re-run for nothing
        let unchanged = self.review.model().fingerprint() == model.fingerprint();
        if unchanged {
            self.review.status = status;
        } else {
            self.review.install_refresh(status, model);
            self.status.clear_enriched();
        }
        match self.review.vcs.head() {
            Ok(head) => self.head = head,
            Err(err) => self.error(err.to_string()),
        }
        match status::load_commit_lists(
            self.review.vcs.as_ref(),
            self.config.ui.recent_commits,
            status::UNPUSHED_LIMIT,
        ) {
            Ok((unpushed, recent)) => {
                self.status.unpushed = unpushed;
                self.status.recent = recent;
            }
            Err(err) => self.error(err.to_string()),
        }
        match status::load_branches(self.review.vcs.as_ref()) {
            Ok(branches) => self.status.branches = branches,
            Err(err) => self.error(err.to_string()),
        }
        self.restore_status_cursor(status_anchor);
        self.refresh_log();
        let swap = against.and_then(|(rev, result)| self.against_swap(&rev, result));
        let mut moved = false;
        if let Some(diff) = self.diff.as_mut() {
            moved = !unchanged || swap.is_some();
            if let Some(model) = swap {
                diff.commit_model = Some(model);
            }
            // invalidating drops the visual selection, so a no-op refresh
            // must leave rows, emphasis, and memos alone
            if moved {
                diff.clear_enriched();
                diff.invalidate();
            }
            diff.ensure_rows(&self.review);
        }
        // a file that joined the diff has no attributes read yet, and the
        // kinds sidebar would keep guessing at it for as long as the view
        // stays open
        if moved {
            self.queue_declared();
        }
        self.restore_diff_cursor(diff_anchor_path);
    }

    /// The recomputed three-dot diff to swap into the open view. `None` when
    /// the view moved on to another source, when the diff did not actually
    /// change (a rebuild carries no emphasis), or when the recompute failed.
    fn against_swap(
        &mut self,
        rev: &str,
        result: Result<diffler_core::model::DiffModel, diffler_core::vcs::VcsError>,
    ) -> Option<diffler_core::model::DiffModel> {
        let shown = self
            .diff
            .as_ref()
            .filter(|diff| diff.source == ReviewSource::against(rev))
            .map(|diff| diff.commit_model.as_ref().map(DiffModel::fingerprint))?;
        match result {
            Ok(model) => (shown != Some(model.fingerprint())).then_some(model),
            Err(err) => {
                self.error(err.to_string());
                None
            }
        }
    }

    fn watcher_unhealthy(&self) -> bool {
        self.watcher_healthy
            .as_ref()
            .is_none_or(|healthy| !healthy.load(Ordering::Relaxed))
    }

    // --- branch transient flows ---

    pub(crate) fn request_network(&mut self, op: NetworkOp, label: &str) {
        let argv = self.review.vcs.network_argv(op);
        self.pending_git = Some(GitOp {
            label: label.to_owned(),
            argv,
        });
        self.info(format!("running git {label}…"));
    }

    fn on_ci_extras(&mut self, extras: crate::ci::RunExtras) -> Flow {
        self.extras = Some(extras);
        Flow::Continue
    }

    fn on_ci_error(&mut self, message: String) -> Flow {
        self.error(message);
        Flow::Continue
    }

    fn on_ci_prs_error(&mut self, message: String) -> Flow {
        // a failed fetch frees the slot, so a later unfold can try again
        self.status.prs_in_flight = false;
        self.error(message);
        Flow::Continue
    }

    fn on_pr_event(&mut self, pr: Option<crate::ci::PullRequest>) -> Flow {
        let anchor = self.status_cursor_anchor();
        self.pr = pr;
        self.pr_checked = true;
        self.restore_status_cursor(anchor);
        Flow::Continue
    }

    /// Report a finished network op: the first non-empty output line as a
    /// success toast (label + summary), or as an error on failure. The queued
    /// refresh (head/log/ahead-behind may have moved) lands after the toast, so
    /// a refresh that fails replaces it with its error. A pending PR
    /// open/switch routes to its own continuation, gated on the fetch's own
    /// label, since several git ops can be in flight and an unrelated one
    /// finishing first must not consume the continuation slots.
    fn git_finished(&mut self, label: &str, ok: bool, output: &str) {
        if label == Self::PR_CREATE_PUSH {
            if ok {
                // the create is addressed against the pushed head
                self.queue_refresh();
                self.after_refresh = Some(AfterRefresh::CreatePr);
                return;
            }
            // the branch never reached the forge, so there is nothing to open
            self.pending_pr_create = None;
        }
        if label.starts_with(Self::PR_FETCH_PREFIX) {
            if let Some(pr) = self.pending_pr_open.take().filter(|_| ok) {
                match self.resolve_pr_range(&pr) {
                    Some((base, head)) => self.open_pr_diff(pr.number, &base, &head),
                    None => self.error("PR head still missing after fetch"),
                }
                return;
            }
            if let Some(branch) = self.pending_pr_switch.take().filter(|_| ok) {
                self.pending_git = Some(GitOp {
                    label: format!("switch {branch}"),
                    argv: vec!["git".to_owned(), "switch".to_owned(), branch],
                });
                return;
            }
        }
        let summary = output
            .lines()
            .map(str::trim)
            .find(|line| !line.is_empty())
            .unwrap_or("")
            .to_owned();
        self.message = None;
        self.queue_refresh();
        if ok {
            if summary.is_empty() {
                self.info(format!("{label} done"));
            } else {
                self.info(format!("{label}: {summary}"));
            }
        } else if self.network_recovery(label, output) {
            // a recovery dialog now owns the screen; suppress the raw error
        } else if summary.is_empty() {
            self.error(format!("{label} failed"));
        } else {
            self.error(summary);
        }
    }
}

fn tree_row_label(node: &crate::tree::TreeNode) -> String {
    match node {
        crate::tree::TreeNode::Dir { name, .. } | crate::tree::TreeNode::File { name, .. } => {
            name.clone()
        }
        // bucket headers are chrome, not content: `/` never matches them
        crate::tree::TreeNode::Section { .. } => String::new(),
    }
}

fn diff_row_text(file: Option<&diffler_core::model::FileDiff>, row: &DiffRow) -> Option<String> {
    match *row {
        DiffRow::Line { hunk, line, .. } => {
            Some(file?.hunks.get(hunk)?.lines.get(line)?.text.clone())
        }
        _ => None,
    }
}

/// Byte offset of the `chars`-th character, for editing the input buffer.
fn byte_index(buffer: &str, chars: usize) -> usize {
    buffer
        .char_indices()
        .nth(chars)
        .map_or(buffer.len(), |(index, _)| index)
}

/// Two left-presses within this window (at about the same cell) are a
/// double-click.
const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);

/// A resolved mouse interaction, screen-independent. Each screen's
/// `*_mouse` handler matches this exhaustively, so adding an interaction means
/// every screen is forced to decide how it responds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MouseGesture {
    /// Wheel notch over `(col, row)`.
    Scroll { col: u16, row: u16, down: bool },
    /// Single left-click: select the thing under the pointer.
    Press { col: u16, row: u16 },
    /// Double left-click: activate it (open, like `<cr>`).
    DoublePress { col: u16, row: u16 },
    /// Left-drag: extend a selection to `(col, row)`.
    Drag { col: u16, row: u16 },
    /// Right-click: cancel the in-progress interaction (e.g. drop a selection).
    Cancel,
}

/// Map a mouse point to a 0-based index into a list rendered in `area` with
/// `scroll` rows hidden above the top; `None` when the point falls outside.
pub(crate) fn hit_index(
    area: ratatui::layout::Rect,
    scroll: usize,
    col: u16,
    row: u16,
) -> Option<usize> {
    let inside =
        col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height;
    inside.then(|| scroll + (row - area.y) as usize)
}

fn empty_head() -> HeadInfo {
    HeadInfo {
        ahead: 0,
        behind: 0,
        branch: None,
        oid7: String::new(),
        subject: String::new(),
        upstream: None,
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::editor::EditorPurpose;

    use crossterm::event::KeyModifiers;

    use super::*;
    use crate::test_support::{Fixture, key, standard_fixture, two_hunk_fixture};
    use diffler_core::session::Anchor;

    fn app() -> (Fixture, App) {
        let fixture = standard_fixture();
        let app = App::new(fixture.review(), LoadedConfig::default());
        (fixture, app)
    }

    #[test]
    fn ci_log_mouse_scrolls_and_double_click_folds() {
        let (_fixture, mut app) = app();
        let raw = "j\tUNKNOWN STEP\t2026-06-20T00:00:00Z ##[group]Build\n\
                   j\tUNKNOWN STEP\t2026-06-20T00:00:01Z compiling\n\
                   j\tUNKNOWN STEP\t2026-06-20T00:00:02Z ##[group]Test\n\
                   j\tUNKNOWN STEP\t2026-06-20T00:00:03Z running\n";
        let mut view = ci_log::CiLogView::parse(raw, &[]);
        view.body = ratatui::layout::Rect::new(0, 1, 40, 10);
        app.ci_log = Some(view);

        app.ci_log_mouse(MouseGesture::Scroll {
            col: 1,
            row: 2,
            down: true,
        });
        assert_eq!(
            app.ci_log.as_ref().expect("view").cursor,
            1,
            "wheel moved cursor"
        );

        // double-click the Build header (top body row) unfolds it
        app.ci_log_mouse(MouseGesture::DoublePress { col: 2, row: 1 });
        let view = app.ci_log.as_ref().expect("view");
        assert_eq!(view.cursor, 0, "click re-seated the cursor on Build");
        assert!(!view.steps[0].folded, "double-click unfolded the step");
    }

    fn type_text(app: &mut App, text: &str) {
        for c in text.chars() {
            app.handle(key(c));
        }
    }

    #[test]
    fn q_quits_from_the_root_screen() {
        let (_fixture, mut app) = app();
        assert_eq!(app.handle(key('q')), Flow::Quit);
    }

    #[test]
    fn back_pops_the_screen_stack_then_quits() {
        let (_fixture, mut app) = app();
        app.handle(key('l'));
        app.handle(key('l'));
        assert_eq!(app.screen(), Screen::Log);
        assert_eq!(app.handle(key('q')), Flow::Continue);
        assert_eq!(app.screens, vec![Screen::Status]);
        assert!(app.log.is_none(), "popping the log screen drops its state");
        assert_eq!(app.handle(key('q')), Flow::Quit);
    }

    #[test]
    fn keymap_follows_the_top_screen() {
        let (_fixture, mut app) = app();
        app.open_working_tree_diff(None);
        // `r` replies in the diff context; on a non-comment row it hints,
        // instead of being swallowed by the status keymap
        app.handle(key('r'));
        let message = app.message.expect("message");
        assert!(message.text.contains("comment"));
    }

    #[test]
    fn popping_the_runs_screen_keeps_the_status_ci_section() {
        use crate::ci::{CiRun, JobStatus, RunId};
        let (_fixture, mut app) = app();
        app.runs = vec![CiRun {
            id: RunId("1".into()),
            name: "CI".into(),
            title: String::new(),
            branch: "main".into(),
            commit: "abc".into(),
            author: String::new(),
            created: None,
            status: JobStatus::Ok,
            url: None,
            remote: None,
        }];
        app.push_screen(Screen::Runs);
        app.pop_screen();
        assert_eq!(app.screen(), Screen::Status);
        assert_eq!(app.runs.len(), 1, "runs survive leaving the Runs screen");
    }

    #[test]
    fn open_runs_without_a_provider_is_a_clean_noop() {
        // the fixture repo has no remote or CI config, so `o` just informs
        let (_fixture, mut app) = app();
        app.handle(key('o'));
        assert_eq!(app.screen(), Screen::Status, "no screen pushed");
        assert!(app.runs.is_empty());
        let message = app.message.expect("message");
        assert!(message.text.contains("provider"), "{}", message.text);
    }

    #[test]
    fn run_detail_event_feeds_the_graph_view() {
        use crate::ci::{CiJob, CiRun, JobId, JobStatus, RunDetail, RunId};
        let (_fixture, mut app) = app();
        app.graph = Some(crate::graph::GraphView::new());
        app.open_run = Some(RunId("1".into()));
        app.push_screen(Screen::Graph);
        assert_eq!(app.screen(), Screen::Graph);
        // a run detail from the poll is mapped onto the live graph
        let detail = RunDetail {
            run: CiRun {
                id: RunId("1".into()),
                name: "CI".into(),
                title: String::new(),
                branch: "main".into(),
                commit: "abc".into(),
                author: String::new(),
                created: None,
                status: JobStatus::Running,
                url: None,
                remote: None,
            },
            jobs: vec![CiJob {
                id: JobId("lint".into()),
                name: "lint".into(),
                status: JobStatus::Ok,
                duration_secs: None,
                needs: vec![],
            }],
        };
        app.handle(AppEvent::CiRunDetail(detail));
        assert!(app.graph.is_some());
        // q backs out and drops the graph state
        app.handle(key('q'));
        assert_eq!(app.screen(), Screen::Status);
        assert!(app.graph.is_none());
    }

    #[test]
    fn graph_run_detail_queues_extras_once_then_stops() {
        use crate::ci::{Artifact, CiRun, JobStatus, RunDetail, RunExtras, RunId};
        let (_fixture, mut app) = app();
        app.graph = Some(crate::graph::GraphView::new());
        app.open_run = Some(RunId("1".into()));
        app.push_screen(Screen::Graph);
        let detail = || RunDetail {
            run: CiRun {
                id: RunId("1".into()),
                name: "CI".into(),
                title: String::new(),
                branch: "main".into(),
                commit: "abc".into(),
                author: String::new(),
                created: None,
                status: JobStatus::Running,
                url: None,
                remote: None,
            },
            jobs: vec![],
        };
        app.handle(AppEvent::CiRunDetail(detail()));
        assert!(
            matches!(app.pending_ci, Some(CiRequest::Extras(_))),
            "first detail queues the extras fetch"
        );
        app.pending_ci = None;
        app.handle(AppEvent::CiExtras(RunExtras {
            artifacts: vec![Artifact {
                name: "a".into(),
                size_bytes: 1,
                expired: false,
            }],
            annotations: vec![],
        }));
        assert!(app.extras.is_some());
        app.handle(AppEvent::CiRunDetail(detail()));
        assert!(
            app.pending_ci.is_none(),
            "extras already present: no re-queue"
        );
        app.handle(key('q'));
        assert!(app.extras.is_none(), "leaving the graph clears extras");
    }

    #[test]
    fn head_reflects_the_fixture() {
        let (_fixture, app) = app();
        assert_eq!(app.head.branch.as_deref(), Some("main"));
        assert_eq!(app.head.subject, "initial commit");
        assert_eq!(app.head.oid7.len(), 7);
    }

    #[test]
    fn send_feedback_bumps_the_epoch() {
        let (_fixture, mut app) = app();
        let rx = app.feedback_tx.subscribe();
        app.handle(key('Z'));
        assert_eq!(app.feedback_epoch(), 1);
        assert!(rx.has_changed().unwrap(), "watchers see the bump");
        let message = app.message.expect("message");
        assert!(message.text.contains("feedback"));
    }

    #[test]
    fn human_comment_add_reply_and_resolve_bump_the_epoch() {
        let (_fixture, mut app) = app();
        app.open_working_tree_diff(None);
        app.open_composer(
            crate::app::composer::ComposerKind::New {
                anchor: diffler_core::session::Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(2),
                    line_end: None,
                    on_old_side: false,
                    line_text: None,
                },
            },
            "why?".to_owned(),
        );
        app.handle(key('\n'));
        assert_eq!(app.feedback_epoch(), 1, "comment add bumps");

        let id = app.review.session.comments[0].id.clone();
        app.open_composer(
            crate::app::composer::ComposerKind::Reply { comment_id: id },
            "because".to_owned(),
        );
        app.handle(key('\n'));
        assert_eq!(app.feedback_epoch(), 2, "reply bumps");
    }

    #[test]
    fn stale_mcp_request_is_dropped_without_touching_the_session() {
        let (_fixture, mut app) = app();
        let id = app
            .review
            .session
            .add_comment(
                Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(2),
                    line_end: None,
                    on_old_side: false,
                    line_text: None,
                },
                "human",
                "why?",
            )
            .id
            .clone();
        let (reply, rx) = tokio::sync::oneshot::channel();
        // the agent timed out and went away before the app got to the event
        drop(rx);
        let flow = app.handle(AppEvent::Mcp(crate::mcp::McpRequest {
            kind: crate::mcp::McpRequestKind::ReplyComment {
                id,
                body: "late reply".to_owned(),
            },
            reply,
        }));
        assert_eq!(flow, Flow::Continue);
        assert!(
            app.review.session.comments[0].replies.is_empty(),
            "stale mutation must not be replayed"
        );
        let message = app.message.expect("message");
        assert_eq!(message.severity, Severity::Info);
        assert!(message.text.contains("dropped stale agent request"));
    }

    #[test]
    fn live_mcp_request_still_answers_on_the_reply_channel() {
        let (_fixture, mut app) = app();
        let (reply, mut rx) = tokio::sync::oneshot::channel();
        app.handle(AppEvent::Mcp(crate::mcp::McpRequest {
            kind: crate::mcp::McpRequestKind::ReviewStatus,
            reply,
        }));
        assert!(
            matches!(rx.try_recv(), Ok(crate::mcp::McpResponse::Status(_))),
            "live requests are answered"
        );
    }

    #[test]
    fn question_mark_opens_the_help_popup_on_every_screen() {
        let (_fixture, mut app) = app();
        for setup in [
            |_: &mut App| {},
            |app: &mut App| {
                app.handle(key('l'));
                app.handle(key('l'));
            },
            |app: &mut App| app.open_working_tree_diff(None),
        ] {
            setup(&mut app);
            app.handle(key('?'));
            assert_eq!(app.modal, Some(Modal::Help), "{:?}", app.screen());
            // the popup owns the keyboard until dismissed
            app.handle(key('j'));
            assert_eq!(app.modal, Some(Modal::Help));
            app.handle(key('q'));
            assert_eq!(app.modal, None);
        }
    }

    #[test]
    fn help_popup_closes_on_question_mark_and_escape() {
        let (_fixture, mut app) = app();
        app.handle(key('?'));
        app.handle(key('?'));
        assert_eq!(app.modal, None);
        app.handle(key('?'));
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Esc,
            KeyModifiers::NONE,
        )));
        assert_eq!(app.modal, None);
    }

    #[test]
    fn two_key_commit_chord_starts_the_commit_flow() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        assert_eq!(app.message, None, "first key of a chord stays silent");
        assert_eq!(app.pending_editor, None);
        app.handle(key('c'));
        let request = app.pending_editor.expect("editor request");
        assert!(matches!(request.purpose, EditorPurpose::Commit { .. }));
    }

    #[test]
    fn commit_flow_with_nothing_staged_hints() {
        let fixture = two_hunk_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.handle(key('c'));
        app.handle(key('c'));
        assert_eq!(app.pending_editor, None);
        let message = app.message.expect("message");
        assert!(message.text.contains("nothing staged"));
    }

    #[test]
    fn commit_flow_writes_the_template_listing_staged_files() {
        let (fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('c'));
        let request = app.pending_editor.clone().expect("editor request");
        let EditorPurpose::Commit { msg_path } = &request.purpose else {
            panic!("expected a commit purpose, got {:?}", request.purpose);
        };
        // the gitdir comes from libgit2, which canonicalizes (macOS tempdirs
        // are symlinked), so compare resolved paths
        assert_eq!(
            msg_path.canonicalize().unwrap(),
            fixture
                .root
                .join(".git/COMMIT_EDITMSG")
                .canonicalize()
                .unwrap()
        );
        let template = std::fs::read_to_string(msg_path).unwrap();
        assert!(template.contains("# Staged:"));
        assert!(template.contains("#\tnew file: ci.yml"));
        // the editor opens the message file itself
        assert_eq!(request.cmd.last().map(String::as_str), msg_path.to_str());
    }

    fn git(dir: &Path, args: &[&str]) {
        let output = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    #[test]
    fn commit_flow_writes_the_template_inside_a_linked_worktree_gitdir() {
        let fixture = standard_fixture();
        let wt = fixture.root.parent().unwrap().join("wt");
        git(
            &fixture.root,
            &["worktree", "add", wt.to_str().unwrap(), "-b", "wt-branch"],
        );
        assert!(wt.join(".git").is_file(), ".git is a gitlink file");
        std::fs::write(wt.join("staged.txt"), "in the worktree\n").unwrap();
        git(&wt, &["add", "staged.txt"]);

        let review = Review::open(&wt).expect("review in worktree");
        let mut app = App::new(review, LoadedConfig::default());
        app.handle(key('c'));
        app.handle(key('c'));
        assert_eq!(app.message, None, "template write must succeed");
        let request = app.pending_editor.clone().expect("editor request");
        let EditorPurpose::Commit { msg_path } = &request.purpose else {
            panic!("expected a commit purpose, got {:?}", request.purpose);
        };
        let template = std::fs::read_to_string(msg_path).unwrap();
        assert!(template.contains("#\tnew file: staged.txt"));
        assert!(
            msg_path.components().any(|c| c.as_os_str() == "worktrees"),
            "message file lives in the external gitdir: {}",
            msg_path.display()
        );
    }

    #[test]
    fn editor_finished_commits_the_stripped_message() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('c'));
        let Some(EditorRequest {
            purpose: EditorPurpose::Commit { msg_path },
            ..
        }) = app.pending_editor.take()
        else {
            panic!("expected a commit request");
        };
        std::fs::write(&msg_path, "add ci config\n\n# comment to strip\n").unwrap();
        app.editor_finished(EditorPurpose::Commit { msg_path }, Ok(true));
        app.settle_refresh();
        assert_eq!(app.section_files(Section::Staged).len(), 0);
        assert_eq!(app.head.subject, "add ci config");
        let message = app.message.expect("message");
        assert!(message.text.starts_with("committed "), "{}", message.text);
        assert!(message.text.contains(&app.head.oid7));
        assert!(message.text.contains("add ci config"));
    }

    #[test]
    fn an_untouched_template_aborts_the_commit() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('c'));
        let request = app.pending_editor.take().expect("editor request");
        let head_before = app.head.oid7.clone();
        app.editor_finished(request.purpose, Ok(true));
        let message = app.message.clone().expect("message");
        assert!(message.text.contains("commit aborted"));
        assert_eq!(app.head.oid7, head_before);
        assert_eq!(app.section_files(Section::Staged).len(), 1);
    }

    #[test]
    fn a_failed_editor_aborts_the_commit() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('c'));
        let request = app.pending_editor.take().expect("editor request");
        app.editor_finished(request.purpose.clone(), Ok(false));
        let message = app.message.clone().expect("message");
        assert!(message.text.contains("commit aborted"));

        app.editor_finished(request.purpose, Err("boom".to_owned()));
        let message = app.message.expect("message");
        assert_eq!(message.severity, Severity::Error);
        assert!(message.text.contains("editor failed"));
        assert!(message.text.contains("boom"));
    }

    #[test]
    fn editor_finished_open_file_refreshes_and_toasts() {
        let (fixture, mut app) = app();
        assert_eq!(app.section_files(Section::Untracked).len(), 1);
        // simulate the editor creating a file while the TUI was suspended
        fixture.write("zzz.md", "new\n");
        app.editor_finished(
            EditorPurpose::OpenFile {
                path: "src/lib.rs".to_owned(),
            },
            Ok(true),
        );
        app.settle_refresh();
        assert_eq!(app.section_files(Section::Untracked).len(), 2);
        assert_eq!(app.message.expect("message").text, "edited src/lib.rs");
    }

    #[test]
    fn commit_transient_opens_on_c_and_resolves_cc() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        assert_eq!(
            app.transient.map(|t| t.kind),
            Some(TransientKind::Commit),
            "c opens the commit transient"
        );
        assert_eq!(app.message, None, "opening a transient is silent");
        app.handle(key('c'));
        assert_eq!(app.transient, None, "the leaf closes the transient");
        let request = app.pending_editor.expect("editor request");
        assert!(matches!(request.purpose, EditorPurpose::Commit { .. }));
    }

    #[test]
    fn escape_aborts_an_open_transient() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        assert!(app.transient.is_some());
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Esc,
            KeyModifiers::NONE,
        )));
        assert_eq!(app.transient, None, "esc closes without dispatching");
        assert_eq!(app.pending_editor, None);
    }

    #[test]
    fn an_unknown_key_in_a_transient_closes_it_with_a_beep() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('z'));
        assert_eq!(app.transient, None);
        let message = app.message.expect("beep message");
        assert_eq!(message.severity, Severity::Info);
        assert!(message.text.contains("no such command"));
    }

    #[test]
    fn the_reveal_timer_gates_the_which_key_panel() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        assert!(app.which_key_panel().is_none(), "no flash before the tick");
        app.handle(AppEvent::Tick);
        assert!(app.which_key_panel().is_some(), "revealed after the tick");
    }

    #[test]
    fn commit_extend_amends_with_the_same_message_no_editor() {
        let (_fixture, mut app) = app();
        // ci.yml is staged in the standard fixture
        let subject_before = app.head.subject.clone();
        app.handle(key('c'));
        app.handle(key('e'));
        app.settle_refresh();
        assert_eq!(app.pending_editor, None, "extend runs without the editor");
        assert_eq!(
            app.section_files(Section::Staged).len(),
            0,
            "index folded in"
        );
        assert_eq!(app.head.subject, subject_before, "message reused");
        let message = app.message.expect("message");
        assert!(message.text.starts_with("amended "), "{}", message.text);
    }

    #[test]
    fn commit_extend_with_nothing_staged_hints() {
        let fixture = two_hunk_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.handle(key('c'));
        app.handle(key('e'));
        assert_eq!(app.pending_editor, None);
        assert!(
            app.message
                .expect("message")
                .text
                .contains("nothing staged")
        );
    }

    #[test]
    fn commit_amend_opens_the_editor_then_amends() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('a'));
        let Some(EditorRequest {
            purpose:
                EditorPurpose::Amend {
                    msg_path,
                    use_index,
                },
            ..
        }) = app.pending_editor.take()
        else {
            panic!("expected an amend request");
        };
        assert!(use_index, "amend folds the index in");
        // the template pre-fills the existing HEAD message
        let template = std::fs::read_to_string(&msg_path).unwrap();
        assert!(template.contains("initial commit"), "{template}");
        std::fs::write(&msg_path, "reworded subject\n").unwrap();
        app.editor_finished(
            EditorPurpose::Amend {
                msg_path,
                use_index,
            },
            Ok(true),
        );
        app.settle_refresh();
        assert_eq!(app.head.subject, "reworded subject");
        assert_eq!(
            app.section_files(Section::Staged).len(),
            0,
            "index folded in"
        );
    }

    #[test]
    fn commit_reword_changes_the_message_keeping_staged_changes() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        app.handle(key('w'));
        let Some(EditorRequest {
            purpose:
                EditorPurpose::Amend {
                    msg_path,
                    use_index,
                },
            ..
        }) = app.pending_editor.take()
        else {
            panic!("expected an amend request");
        };
        assert!(!use_index, "reword keeps HEAD's tree");
        std::fs::write(&msg_path, "just a reword\n").unwrap();
        app.editor_finished(
            EditorPurpose::Amend {
                msg_path,
                use_index,
            },
            Ok(true),
        );
        app.settle_refresh();
        assert_eq!(app.head.subject, "just a reword");
        // the previously staged ci.yml stays staged: reword left the tree alone
        assert!(
            app.section_files(Section::Staged)
                .iter()
                .any(|f| f.path == "ci.yml"),
            "staged change preserved across a reword"
        );
    }

    #[test]
    fn a_failed_editor_aborts_the_amend() {
        let (_fixture, mut app) = app();
        let head_before = app.head.oid7.clone();
        app.handle(key('c'));
        app.handle(key('w'));
        let request = app.pending_editor.take().expect("editor request");
        // a non-zero editor exit aborts without rewriting HEAD
        app.editor_finished(request.purpose, Ok(false));
        let message = app.message.expect("message");
        assert!(message.text.contains("amend aborted"));
        assert_eq!(app.head.oid7, head_before, "HEAD unchanged");
    }

    #[test]
    fn config_can_rebind_a_transient_sub_key() {
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded
            .config
            .keys
            .commit
            .insert("amend".to_owned(), "m".to_owned());
        let mut app = App::new(fixture.review(), loaded);
        app.handle(key('c'));
        app.handle(key('m'));
        let request = app.pending_editor.expect("editor request");
        assert!(matches!(
            request.purpose,
            EditorPurpose::Amend {
                use_index: true,
                ..
            }
        ));
    }

    #[test]
    fn branch_transient_creates_and_checks_out_a_branch() {
        let (_fixture, mut app) = app();
        app.handle(key('b'));
        assert_eq!(
            app.transient.map(|t| t.kind),
            Some(TransientKind::Branch),
            "b opens the branch transient"
        );
        app.handle(key('c'));
        assert_eq!(app.transient, None, "a resolving key closes the transient");
        assert!(matches!(app.modal, Some(Modal::Input { .. })));
        type_text(&mut app, "feat/x");
        app.handle(key('\n'));
        app.settle_refresh();
        assert_eq!(app.modal, None);
        assert_eq!(app.head.branch.as_deref(), Some("feat/x"));
        let message = app.message.expect("message");
        assert!(message.text.contains("switched to new branch feat/x"));
    }

    #[test]
    fn branch_transient_n_creates_without_checkout() {
        let (_fixture, mut app) = app();
        app.handle(key('b'));
        app.handle(key('n'));
        type_text(&mut app, "feat/y");
        app.handle(key('\n'));
        assert_eq!(app.head.branch.as_deref(), Some("main"), "HEAD unmoved");
        let branches = app.review.vcs.branches().unwrap();
        assert!(branches.iter().any(|b| b.name == "feat/y" && !b.is_head));
        let message = app.message.expect("message");
        assert!(message.text.contains("created branch feat/y"));
    }

    #[test]
    fn duplicate_branch_name_surfaces_the_error() {
        let (fixture, mut app) = app();
        fixture.branch("feat/dup");
        app.handle(key('b'));
        app.handle(key('n'));
        type_text(&mut app, "feat/dup");
        app.handle(key('\n'));
        let message = app.message.expect("message");
        assert_eq!(message.severity, Severity::Error);
    }

    /// Open the branch list and move the cursor onto `name`.
    fn branch_list_cursor_to(app: &mut App, action_key: char, name: &str) {
        app.handle(key('b'));
        app.handle(key(action_key));
        let Some(Modal::BranchList { branches, .. }) = &app.modal else {
            panic!("expected the branch list, got {:?}", app.modal);
        };
        let target = branches
            .iter()
            .position(|b| b.name == name)
            .expect("branch listed");
        for _ in 0..target {
            app.handle(key('j'));
        }
    }

    #[test]
    fn branch_list_checks_out_the_selected_branch() {
        let (fixture, mut app) = app();
        fixture.branch("feat/topic");
        branch_list_cursor_to(&mut app, 'b', "feat/topic");
        app.handle(key('\n'));
        app.settle_refresh();
        assert_eq!(app.modal, None);
        assert_eq!(app.head.branch.as_deref(), Some("feat/topic"));
        let message = app.message.expect("message");
        assert!(message.text.contains("checked out feat/topic"));
    }

    #[test]
    fn branch_list_delete_opens_confirm_modal() {
        let (fixture, mut app) = app();
        fixture.branch("feat/dead");
        branch_list_cursor_to(&mut app, 'D', "feat/dead");
        app.handle(key('\n'));
        // Enter should open the confirm modal, not delete immediately
        let Some(Modal::Confirm {
            message,
            on_confirm,
        }) = &app.modal
        else {
            panic!("expected a confirm modal, got {:?}", app.modal);
        };
        assert!(message.contains("feat/dead"));
        assert_eq!(*on_confirm, PendingOp::DeleteBranch("feat/dead".to_owned()));
        // branch still exists before confirming
        let branches = app.review.vcs.branches().unwrap();
        assert!(branches.iter().any(|b| b.name == "feat/dead"));
    }

    #[test]
    fn branch_delete_confirmed_with_y_deletes_the_branch() {
        let (fixture, mut app) = app();
        fixture.branch("feat/dead");
        branch_list_cursor_to(&mut app, 'D', "feat/dead");
        app.handle(key('\n'));
        app.handle(key('y'));
        assert_eq!(app.modal, None);
        let branches = app.review.vcs.branches().unwrap();
        assert!(branches.iter().all(|b| b.name != "feat/dead"));
        let message = app.message.expect("message");
        assert!(message.text.contains("deleted branch feat/dead"));
    }

    #[test]
    fn branch_delete_cancelled_with_n_keeps_the_branch() {
        let (fixture, mut app) = app();
        fixture.branch("feat/dead");
        branch_list_cursor_to(&mut app, 'D', "feat/dead");
        app.handle(key('\n'));
        app.handle(key('n'));
        assert_eq!(app.modal, None);
        let branches = app.review.vcs.branches().unwrap();
        assert!(branches.iter().any(|b| b.name == "feat/dead"));
    }

    #[test]
    fn deleting_the_checked_out_branch_surfaces_the_error() {
        let (_fixture, mut app) = app();
        branch_list_cursor_to(&mut app, 'D', "main");
        app.handle(key('\n'));
        // confirm the deletion attempt
        app.handle(key('y'));
        let message = app.message.expect("message");
        assert_eq!(message.severity, Severity::Error);
        let branches = app.review.vcs.branches().unwrap();
        assert!(branches.iter().any(|b| b.name == "main"));
    }

    #[test]
    fn branch_list_escape_closes_the_picker() {
        let (fixture, mut app) = app();
        fixture.branch("feat/topic");
        app.handle(key('b'));
        app.handle(key('b'));
        assert!(matches!(app.modal, Some(Modal::BranchList { .. })));
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Esc,
            KeyModifiers::NONE,
        )));
        assert_eq!(app.modal, None, "esc closes the branch picker");
    }

    #[test]
    fn push_with_an_upstream_queues_a_plain_push() {
        let (_fixture, mut app) = app();
        app.head.upstream = Some("origin/main".to_owned());
        app.handle(key('P'));
        assert_eq!(app.transient.map(|t| t.kind), Some(TransientKind::Push));
        app.handle(key('p'));
        let git = app.pending_git.clone().expect("pending git op");
        assert_eq!(git.label, "push");
        assert_eq!(git.argv, vec!["git".to_owned(), "push".to_owned()]);
        assert!(app.message.expect("running status").text.contains("push"));
    }

    #[test]
    fn push_without_an_upstream_confirms_set_upstream_to_the_only_remote() {
        let fixture = standard_fixture();
        fixture.remote("codeberg", "https://example.invalid/r.git");
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.head.upstream = None;
        app.handle(key('P'));
        app.handle(key('p'));
        assert!(
            matches!(app.modal, Some(Modal::Confirm { .. })),
            "no upstream asks first: {:?}",
            app.modal
        );
        app.confirm_modal();
        let git = app.pending_git.clone().expect("pending git op");
        assert_eq!(git.label, "push -u");
        assert_eq!(
            git.argv,
            vec!["git", "push", "-u", "codeberg", "HEAD"]
                .into_iter()
                .map(str::to_owned)
                .collect::<Vec<_>>(),
            "sets upstream to the actual remote, not a hardcoded origin"
        );
    }

    #[test]
    fn push_with_multiple_remotes_opens_a_remote_chooser() {
        let fixture = standard_fixture();
        fixture.remote("origin", "https://example.invalid/a.git");
        fixture.remote("codeberg", "https://example.invalid/b.git");
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.head.upstream = None;
        app.push_set_upstream();
        assert!(
            matches!(app.modal, Some(Modal::RemoteList { .. })),
            "multiple remotes ask which one: {:?}",
            app.modal
        );
    }

    #[test]
    fn a_rejected_push_offers_force_with_lease() {
        let (_fixture, mut app) = app();
        app.last_push_argv = Some(vec!["git".to_owned(), "push".to_owned()]);
        app.handle(AppEvent::GitDone {
            label: "push".to_owned(),
            ok: false,
            output: " ! [rejected]        main -> main (non-fast-forward)\n".to_owned(),
        });
        assert!(
            matches!(app.modal, Some(Modal::Confirm { .. })),
            "a non-fast-forward asks before forcing: {:?}",
            app.modal
        );
        app.confirm_modal();
        let git = app.pending_git.clone().expect("pending git op");
        assert_eq!(
            git.argv,
            vec!["git", "push", "--force-with-lease"]
                .into_iter()
                .map(str::to_owned)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn a_rejected_force_with_lease_does_not_re_offer_force() {
        let (_fixture, mut app) = app();
        app.handle(AppEvent::GitDone {
            label: "push --force-with-lease".to_owned(),
            ok: false,
            output: "! [rejected] (stale info)\n".to_owned(),
        });
        assert_eq!(
            app.modal, None,
            "no force loop; the conflict is a real error"
        );
        assert_eq!(app.message.expect("error").severity, Severity::Error);
    }

    #[test]
    fn a_diverged_pull_offers_rebase_merge_or_force() {
        let (_fixture, mut app) = app();
        app.head.upstream = Some("origin/main".to_owned());
        app.handle(AppEvent::GitDone {
            label: "pull".to_owned(),
            ok: false,
            output:
                "hint: You have divergent branches and need to specify how to reconcile them.\n"
                    .to_owned(),
        });
        assert!(matches!(app.modal, Some(Modal::PullDiverged { .. })));
        app.handle(key('r'));
        assert_eq!(
            app.pending_git.take().expect("rebase op").argv,
            vec!["git", "pull", "--rebase"]
                .into_iter()
                .map(str::to_owned)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn force_pull_needs_a_second_destructive_confirm() {
        let (_fixture, mut app) = app();
        app.modal = Some(Modal::PullDiverged {
            upstream: "origin/main".to_owned(),
        });
        app.handle(key('f'));
        assert!(
            matches!(app.modal, Some(Modal::Confirm { .. })),
            "force asks a second time before discarding: {:?}",
            app.modal
        );
        app.confirm_modal();
        assert_eq!(
            app.pending_git.take().expect("reset op").argv,
            vec!["git", "reset", "--hard", "@{u}"]
                .into_iter()
                .map(str::to_owned)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn pull_with_an_upstream_queues_a_plain_pull_and_fetch_leaves_queue_argv() {
        let (_fixture, mut app) = app();
        app.head.upstream = Some("origin/main".to_owned());
        app.handle(key('p'));
        app.handle(key('p'));
        assert_eq!(
            app.pending_git.take().expect("pull op").argv,
            vec!["git".to_owned(), "pull".to_owned()]
        );
        app.handle(key('f'));
        app.handle(key('f'));
        assert_eq!(
            app.pending_git.take().expect("fetch op").argv,
            vec!["git".to_owned(), "fetch".to_owned()]
        );
        app.handle(key('f'));
        app.handle(key('a'));
        assert_eq!(
            app.pending_git.take().expect("fetch-all op").argv,
            vec!["git".to_owned(), "fetch".to_owned(), "--all".to_owned()]
        );
    }

    #[test]
    fn git_done_success_shows_a_status_summary() {
        let (_fixture, mut app) = app();
        app.handle(AppEvent::GitDone {
            label: "push".to_owned(),
            ok: true,
            output: "Everything up-to-date\n".to_owned(),
        });
        let message = app.message.expect("status");
        assert_eq!(message.severity, Severity::Info);
        assert!(message.text.contains("push"), "{}", message.text);
        assert!(
            message.text.contains("Everything up-to-date"),
            "{}",
            message.text
        );
    }

    #[test]
    fn git_done_for_an_unrelated_op_leaves_a_pending_pr_continuation_alone() {
        let (_fixture, mut app) = app();
        app.pending_pr_open = Some(crate::ci::PullRequest {
            number: 7,
            title: String::new(),
            url: None,
            base_ref: "main".to_owned(),
            head_ref: "topic".to_owned(),
            head_oid: String::new(),
            author: String::new(),
        });
        // ops run detached, so a pull can finish while the PR fetch is still
        // in flight. Its completion must not consume the continuation
        app.handle(AppEvent::GitDone {
            label: "pull".to_owned(),
            ok: true,
            output: "Already up to date.\n".to_owned(),
        });
        assert!(
            app.pending_pr_open.is_some(),
            "unrelated op consumed the pending PR open"
        );
        let message = app
            .message
            .take()
            .expect("the pull's own toast still shows");
        assert!(message.text.contains("pull"), "{}", message.text);
        app.handle(AppEvent::GitDone {
            label: App::pr_fetch_label(7),
            ok: true,
            output: String::new(),
        });
        assert!(
            app.pending_pr_open.is_none(),
            "the matching fetch consumes the continuation"
        );
    }

    #[test]
    fn git_done_failure_surfaces_the_first_stderr_line_as_an_error() {
        let (_fixture, mut app) = app();
        app.handle(AppEvent::GitDone {
            label: "push".to_owned(),
            ok: false,
            output: "fatal: No configured push destination.\nmore detail\n".to_owned(),
        });
        let message = app.message.expect("status");
        assert_eq!(message.severity, Severity::Error);
        assert_eq!(message.text, "fatal: No configured push destination.");
    }

    #[test]
    fn repo_changed_refreshes_and_flashes_the_indicator() {
        let (fixture, mut app) = app();
        assert_eq!(app.section_files(Section::Untracked).len(), 1);
        fixture.write("zzz.md", "new\n");
        app.handle(AppEvent::RepoChanged);
        app.settle_refresh();
        assert_eq!(app.section_files(Section::Untracked).len(), 2);
        assert_eq!(app.refresh_flash, REFRESH_FLASH_TICKS);
        app.handle(AppEvent::Tick);
        assert_eq!(app.refresh_flash, REFRESH_FLASH_TICKS - 1);
    }

    #[test]
    fn a_tick_that_paints_nothing_reports_idle_so_the_loop_skips_the_draw() {
        let (_fixture, mut app) = app();
        // the flash from opening still has frames to give
        while app.refresh_flash > 0 {
            assert_eq!(app.handle(AppEvent::Tick), Flow::Continue);
        }
        assert_eq!(app.handle(AppEvent::Tick), Flow::Idle);
    }

    #[test]
    fn the_tick_that_reveals_the_which_key_panel_asks_for_a_draw() {
        let (_fixture, mut app) = app();
        while app.handle(AppEvent::Tick) == Flow::Continue {}
        app.handle(key('b'));
        assert!(
            app.which_key_panel().is_none(),
            "the panel waits out its delay"
        );
        let mut ticks = 0;
        while app.handle(AppEvent::Tick) == Flow::Idle {
            ticks += 1;
            assert!(ticks < 20, "the panel never revealed");
        }
        assert!(app.which_key_panel().is_some());
    }

    #[test]
    fn an_expiring_chord_asks_for_a_draw_because_the_hint_row_shows_it() {
        let (_fixture, mut app) = app();
        while app.handle(AppEvent::Tick) == Flow::Continue {}
        app.handle(key('g')); // half of `gg`
        assert!(!app.pending.is_empty());
        let mut ticks = 0;
        while app.handle(AppEvent::Tick) == Flow::Idle {
            ticks += 1;
            assert!(ticks < 20, "the chord never expired");
        }
        assert!(app.pending.is_empty());
    }

    #[test]
    fn the_clock_behind_every_age_keeps_moving() {
        let (_fixture, mut app) = app();
        // a clock stopped in the past is what makes a run read "0s" forever
        app.now_unix = 0;
        for _ in 0..CLOCK_TICKS {
            app.handle(AppEvent::Tick);
        }
        assert!(
            app.now_unix > 0,
            "the tick moved the clock on: {}",
            app.now_unix
        );
    }

    #[test]
    fn a_tick_between_clock_steps_paints_nothing() {
        let (_fixture, mut app) = app();
        app.now_unix = now_unix();
        let quiet = (1..CLOCK_TICKS).all(|_| app.handle(AppEvent::Tick) == Flow::Idle);
        assert!(quiet, "idle output stays at zero between clock steps");
    }

    #[test]
    fn tick_fallback_polls_only_while_the_watcher_is_unhealthy() {
        let (fixture, mut app) = app();
        let healthy = Arc::new(AtomicBool::new(true));
        app.watcher_healthy = Some(Arc::clone(&healthy));
        fixture.write("zzz.md", "new\n");
        for _ in 0..FALLBACK_REFRESH_TICKS {
            app.handle(AppEvent::Tick);
        }
        app.settle_refresh();
        assert_eq!(
            app.section_files(Section::Untracked).len(),
            1,
            "a healthy watcher means no tick polling"
        );
        healthy.store(false, Ordering::Relaxed);
        for _ in 0..FALLBACK_REFRESH_TICKS {
            app.handle(AppEvent::Tick);
        }
        app.settle_refresh();
        assert_eq!(
            app.section_files(Section::Untracked).len(),
            2,
            "the unhealthy fallback picked up the change"
        );
    }

    #[test]
    fn pending_chord_expires_after_the_timeout() {
        let (_fixture, mut app) = app();
        app.handle(key('c'));
        for _ in 0..PENDING_TIMEOUT_TICKS {
            app.handle(AppEvent::Tick);
        }
        // the second `c` starts a fresh sequence instead of completing `cc`
        app.handle(key('c'));
        assert_eq!(app.message, None);
    }

    #[test]
    fn unknown_keys_are_a_no_op() {
        let (_fixture, mut app) = app();
        assert_eq!(app.handle(key('z')), Flow::Continue);
        assert_eq!(app.message, None);
        assert_eq!(app.status.cursor, 0);
    }

    #[test]
    fn unknown_theme_surfaces_a_warning() {
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded.config.ui.theme = "nope".to_owned();
        let app = App::new(fixture.review(), loaded);
        let message = app.message.expect("warning");
        assert_eq!(message.severity, Severity::Warning);
        assert!(message.text.contains("nope"));
    }

    #[test]
    fn config_key_override_reaches_the_keymap() {
        let fixture = standard_fixture();
        let mut loaded = LoadedConfig::default();
        loaded
            .config
            .keys
            .status
            .insert("move_down".to_owned(), "n".to_owned());
        let mut app = App::new(fixture.review(), loaded);
        app.handle(key('n'));
        assert_eq!(app.status.cursor, 1);
    }

    #[test]
    fn input_modal_edits_the_buffer() {
        let (_fixture, mut app) = app();
        app.modal = Some(Modal::Input {
            title: "Test".to_owned(),
            buffer: String::new(),
            cursor: 0,
            on_submit: InputOp::CreateBranch { checkout: false },
        });
        for c in "héllo".chars() {
            app.handle(key(c));
        }
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Backspace,
            KeyModifiers::NONE,
        )));
        let Some(Modal::Input { buffer, cursor, .. }) = &app.modal else {
            panic!("modal should still be up");
        };
        assert_eq!(buffer, "héll");
        assert_eq!(*cursor, 4);
        // Esc cancels without touching the session
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Esc,
            KeyModifiers::NONE,
        )));
        assert_eq!(app.modal, None);
        assert!(app.review.session.comments.is_empty());
    }

    #[test]
    fn alt_enter_inserts_a_newline_and_the_body_keeps_both_lines() {
        let (_fixture, mut app) = app();
        app.open_working_tree_diff(None);
        app.open_composer(
            crate::app::composer::ComposerKind::New {
                anchor: diffler_core::session::Anchor {
                    file: "src/lib.rs".to_owned(),
                    line: Some(2),
                    line_end: None,
                    on_old_side: false,
                    line_text: None,
                },
            },
            String::new(),
        );
        for c in "first".chars() {
            app.handle(key(c));
        }
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Enter,
            KeyModifiers::ALT,
        )));
        for c in "second".chars() {
            app.handle(key(c));
        }
        let composer = app.diff.as_ref().and_then(|d| d.composer.as_ref());
        let composer = composer.expect("the composer is still open");
        assert_eq!(composer.buffer, "first\nsecond");
        assert_eq!(composer.cursor, 12);
        app.handle(key('\n'));
        assert!(!app.composer_open());
        assert_eq!(app.review.session.comments[0].body, "first\nsecond");
    }

    #[test]
    fn ctrl_j_is_a_newline_fallback() {
        let (_fixture, mut app) = app();
        app.modal = Some(Modal::Input {
            title: "Test".to_owned(),
            buffer: "ab".to_owned(),
            cursor: 1,
            on_submit: InputOp::CreateBranch { checkout: false },
        });
        app.handle(AppEvent::Key(KeyEvent::new(
            KeyCode::Char('j'),
            KeyModifiers::CONTROL,
        )));
        let Some(Modal::Input { buffer, cursor, .. }) = &app.modal else {
            panic!("modal should still be up");
        };
        assert_eq!(buffer, "a\nb");
        assert_eq!(*cursor, 2);
    }

    #[test]
    fn empty_input_submit_is_a_cancel() {
        let (_fixture, mut app) = app();
        app.modal = Some(Modal::Input {
            title: "Test".to_owned(),
            buffer: "   ".to_owned(),
            cursor: 3,
            on_submit: InputOp::CreateBranch { checkout: false },
        });
        app.handle(key('\n'));
        assert_eq!(app.modal, None);
        assert_eq!(app.message, None, "no error: empty submit just closes");
    }
}
