Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-deploykey/src/lib.rs

//! **`facett-deploykey`** — the 🔑 SSH deploy-key surface, as three real panes.
//!
//! ## The problem this pane exists for
//!
//! The forge fetches its repos over SSH (the nordisk organisation, polled every
//! 60 s). On 2026-07-22, **eight repos failed every single poll** for most of a day
//! with `SSH remote … needs a deploy key, but none was found`, and **nothing in the
//! UI said so.** The only way to learn it was `journalctl`. Everything below is in
//! service of making that self-diagnosable and self-fixable:
//!
//! | idx | pane | what it owns |
//! |---|---|---|
//! | 0 | **Key** | the state, the identity, the public key, Copy, and where to paste it |
//! | 1 | **Remotes** | the REAL per-remote fetch state, with the actual error |
//! | 2 | **Where it came from** | the resolution ladder, every rung, and which one answered |
//!
//! ## Five states, and they are five
//!
//! [`nornir_git::deploykey::KeyState`] — reused, not redefined here:
//!
//! * **NO KEY FOUND** — nothing on any rung. Every fetch fails before it starts.
//! * **MINTED — NOT REGISTERED** — a key was just created and no remote has been
//!   told about it. Its auth failures look identical to a missing key's, which is
//!   why it is its own state and not folded into the next one.
//! * **KEY PRESENT — NEVER USED** — a key exists and *nothing has been probed*.
//!   This renders as its own thing and **is not green**.
//! * **KEY REJECTED BY REMOTE** — a remote refused it. The one a deploy key fixes.
//! * **FETCHING** — at least one remote authenticated; the pane reports the failing
//!   count beside the good one, because 11 green and 8 red is not "fine".
//!
//! The third is the load-bearing one. Conflating "no data" with "all good" is the
//! bug pattern that bit this codebase repeatedly in one day — a golden-file test
//! where a missing snapshot counted as PASS, an undeclared feature space reading as
//! vacuously green, a coverage table empty because nobody ran the command. So
//! [`KeyState::is_green`](nornir_git::deploykey::KeyState::is_green) is false for
//! everything except a probe that actually came back, and the pane paints
//! never-probed rows in their own colour with the words "not probed".
//!
//! ## The identity is `dwarves`, and the pane says which path answered
//!
//! nornir used to be its own systemd service; today dwarves compiles nornir in and
//! there is ONE service. So the default key is `~/.dwarves/ssh/id_ed25519` with the
//! comment `dwarves@<host>`, and no environment variable is involved in the happy
//! path. The nornir-named paths still resolve — a live box must keep fetching — and
//! pane 2 shows exactly which rung answered, flagging a legacy or env-override
//! answer. That is the property that turns a stale config from a mystery into a line
//! of text.
//!
//! ## `Facet`, deliberately, and not `Panel`
//!
//! [`DeployKeyDeck`] implements **`Facet` only**. facett-core carries a blanket
//! `impl<T: Facet> Panel for T`, so it is a `Panel` for free with nothing to
//! disambiguate — implementing both by hand is what produced `E0034` in dwarves'
//! pane. Same rule, same reason, as `facett_buildthing::BuildThingDeck`, which this
//! deck's `view_pane(idx, ui) -> Vec<Msg>` seam mirrors so a `facett_app::scene` host
//! gets real panes with its own chrome, fold, minimise and maximise.
//!
//! ## What this crate does NOT do
//!
//! It never touches the network and never runs a probe: `view_pane` returns
//! [`Msg::ProbeAll`] / [`Msg::Mint`] / [`Msg::Relabel`] and the **host** does the
//! blocking work on a named worker thread. It also never reads the PRIVATE key —
//! the private path is displayed as a path and nothing more. There is no code path
//! in this crate that can put private key material on screen, in a log, or on a
//! clipboard.

use egui::{Sense, Ui};
use facett_core::{Facet, FacetCaps};
use nornir_git::deploykey::{
    key_state, KeyCandidate, KeyOrigin, KeyState, ProbeOutcome, RemoteProbe, Resolution,
};

/// What a deck pane asks the host to do. Returned by [`DeployKeyDeck::view_pane`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Msg {
    /// Put this text on the clipboard (the public key line).
    Copy(String),
    /// Mint dwarves' own deploy key at the default path (`~/.dwarves/ssh`).
    Mint,
    /// Rewrite the public key's OpenSSH comment to `dwarves@<host>`. Cosmetic: the
    /// key material is unchanged and nothing has to be re-registered.
    Relabel,
    /// Probe every known remote for real.
    ProbeAll,
    /// Probe one remote.
    Probe(String),
}

/// The key pane's index.
pub const PANE_KEY: usize = 0;
/// The remotes pane's index.
pub const PANE_REMOTES: usize = 1;
/// The resolution-ladder pane's index.
pub const PANE_LADDER: usize = 2;
/// How many panes the deck holds.
pub const PANE_COUNT: usize = 3;

/// Everything the host measured, handed to the deck as one value.
///
/// The host owns the filesystem and the network; the deck owns the pixels. Every
/// field here is a MEASUREMENT, including the absences — `probes` carries a row per
/// known remote with [`ProbeOutcome::NotProbed`] until one is actually run, so a
/// fleet nobody has asked about renders as a fleet nobody has asked about.
#[derive(Debug, Clone, Default)]
pub struct KeySnapshot {
    /// The ladder and which rung answered.
    pub resolution: Resolution,
    /// The public key line to paste (`ssh-ed25519 AAAA… dwarves@oden`), if any.
    pub public_line: Option<String>,
    /// The OpenSSH comment — the identity a human reads in a deploy-key list.
    pub identity: Option<String>,
    /// One row per known remote.
    pub probes: Vec<RemoteProbe>,
    /// A key was minted in this session and has not been registered anywhere.
    pub minted_this_session: bool,
    /// A probe sweep is in flight (the host's worker is running).
    pub probing: bool,
    /// The last action's outcome, verbatim (a mint error, a relabel confirmation).
    pub notice: Option<String>,
}

impl KeySnapshot {
    /// The state — derived from the same reused model the CLI uses.
    pub fn state(&self) -> KeyState {
        key_state(&self.resolution, self.minted_this_session, &self.probes)
    }

    /// **The identity still names the old service** (`nornir@…`) on a box whose only
    /// service is dwarves.
    ///
    /// Reads [`identity`](Self::identity) — the value the HOST measured and put in
    /// this snapshot — and NOT `Resolution::identity_is_stale`, which re-reads the
    /// `.pub` file from disk. Both answers are correct in their own place (the CLI
    /// has no snapshot and should hit the disk), but a pane that renders one field
    /// and tests another is a pane that can disagree with itself on screen. One
    /// source per surface.
    pub fn identity_is_stale(&self) -> bool {
        self.identity.as_deref().is_some_and(|i| i.starts_with("nornir@"))
    }
}

/// **The deploy-key deck.**
pub struct DeployKeyDeck {
    /// What the host measured.
    pub snap: KeySnapshot,
    /// Divider fractions for the single-body path: key | remotes | ladder.
    ratios: [f32; 2],
    /// Messages raised this frame, drained by the host.
    pending: Vec<Msg>,
    /// The text the next Ctrl-C should yield (the public key, never the private).
    copy_buffer: Option<String>,
}

impl Default for DeployKeyDeck {
    fn default() -> Self {
        DeployKeyDeck {
            snap: KeySnapshot::default(),
            ratios: [0.44, 0.34],
            pending: Vec::new(),
            copy_buffer: None,
        }
    }
}

impl DeployKeyDeck {
    /// A deck over a host-supplied snapshot.
    pub fn new(snap: KeySnapshot) -> Self {
        DeployKeyDeck { snap, ..Default::default() }
    }

    /// Replace the snapshot (the host calls this when its worker reports).
    pub fn set_snapshot(&mut self, snap: KeySnapshot) {
        self.snap = snap;
    }

    /// A **seeded demo deck** — the fixture the facett-demo gallery mounts, so the
    /// surface renders POPULATED with no host, no filesystem and no network. The
    /// public line + `dwarves@oden` identity, and a mixed probe fleet: two remotes
    /// authenticated, one rejected (the state a deploy key fixes), one never probed
    /// (rendered as its own thing, NOT green). Overall state: `FETCHING`, with the
    /// failing count beside the good one — because 2 green and 1 red is not "fine".
    ///
    /// ## The ladder is the WHOLE ladder now
    ///
    /// It used to be one rung. That made [`PANE_LADDER`] — the pane literally
    /// titled "Where the key came from" — vacuous: a ladder with one rung shows
    /// nothing about precedence, which is the only thing that pane exists to show.
    /// It was also a state the real resolver can never produce. `Resolution`
    /// gained `confined` (nornir-git, 2026-09-10) and made that visible: a
    /// resolution is one-rung ONLY when the ladder was confined to an explicit
    /// override, and this one is not confined. `confined: false` with one
    /// candidate depicted something impossible.
    ///
    /// So the fixture now carries the four rungs an unconfigured box actually
    /// has, in real precedence order, with `DwarvesHome` — the no-configuration
    /// default — present and chosen, and the rest absent. That is what an ordinary box looks like, it makes the pane
    /// demonstrate the thing it advertises, and it makes `confined: false`
    /// truthful rather than merely well-reading.
    ///
    /// ## Why the paths are fictional
    ///
    /// The home rungs were hardcoded to `~/...` — one developer's
    /// real key directory, baked into a shipped demo gallery. Nothing ever READ
    /// it (this is a fabricated struct, not a resolver run), so it was never the
    /// credential hazard the test fixtures were; it was a real person's home path
    /// on display in a demo. `/home/demo` matches the `DEMOfixture` convention the
    /// seeded public line already uses, and is nobody's.
    ///
    /// The two absolute rungs keep their real values: `/home/dwarves/.ssh` and
    /// `/home/nornir/.ssh` are not personal paths, they are the system-user rungs
    /// the ladder genuinely has, and showing them is the point of the pane.
    #[must_use]
    pub fn demo() -> Self {
        let home = std::path::Path::new("/home/demo");
        let dir = home.join(".dwarves/ssh");
        // The real ladder, in the real order. Only the default rung holds a key.
        let rung = |origin: KeyOrigin, d: std::path::PathBuf, present: bool| KeyCandidate {
            private: d.join("id_ed25519"),
            public: d.join("id_ed25519.pub"),
            dir: d,
            origin,
            present,
        };
        let resolution = Resolution {
            // FOUR rungs, not six: the two `$..._SSH_DIR` rungs are pushed only
            // when those vars are SET, so depicting them would depict a box that
            // has escape hatches configured — the opposite of the no-configuration
            // default this fixture is showing.
            candidates: vec![
                rung(KeyOrigin::DwarvesHome, dir.clone(), true),
                rung(KeyOrigin::DwarvesServiceUser, "/home/dwarves/.ssh".into(), false),
                rung(KeyOrigin::NornirHomeLegacy, home.join(".nornir/ssh"), false),
                rung(KeyOrigin::NornirServiceUserLegacy, "/home/nornir/.ssh".into(), false),
            ],
            chosen: Some(0),
            // A depicted resolution, and what it depicts is an ordinary box: the
            // ladder ran in full and the default rung answered.
            confined: false,
        };
        let probe = |remote: &str, outcome: ProbeOutcome, detail: &str, ms: u64| RemoteProbe {
            remote: remote.into(),
            outcome,
            detail: detail.into(),
            elapsed_ms: ms,
        };
        DeployKeyDeck::new(KeySnapshot {
            resolution,
            public_line: Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDEMOfixture dwarves@oden".into()),
            identity: Some("dwarves@oden".into()),
            probes: vec![
                probe(
                    "ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
                    ProbeOutcome::Authenticated { head: Some("84ab12cd".into()) },
                    "84 ref(s) advertised",
                    412,
                ),
                probe(
                    "ssh://git@git.gunnar.rs:2222/nordisk/facett.git",
                    ProbeOutcome::Authenticated { head: Some("ca0b1129".into()) },
                    "61 ref(s) advertised",
                    388,
                ),
                probe(
                    "ssh://git@git.gunnar.rs:2222/nordisk/holger.git",
                    ProbeOutcome::AuthRejected,
                    "ssh publickey auth rejected for git@git.gunnar.rs",
                    530,
                ),
                probe("ssh://git@git.gunnar.rs:2222/nordisk/skade.git", ProbeOutcome::NotProbed, "", 0),
            ],
            minted_this_session: false,
            probing: false,
            notice: None,
        })
    }

    /// How many panes — what a host declares scene slots from.
    pub fn pane_count(&self) -> usize {
        PANE_COUNT
    }

    /// The pane title at `idx`.
    pub fn pane_title(&self, idx: usize) -> Option<String> {
        match idx {
            PANE_KEY => Some(format!("Deploy key — {}", self.snap.state().as_str())),
            PANE_REMOTES => {
                let probed =
                    self.snap.probes.iter().filter(|p| p.outcome != ProbeOutcome::NotProbed).count();
                Some(format!("Remotes — {probed}/{} probed", self.snap.probes.len()))
            }
            PANE_LADDER => Some("Where the key came from".to_string()),
            _ => None,
        }
    }

    /// Take the messages raised since the last call.
    pub fn take_msgs(&mut self) -> Vec<Msg> {
        std::mem::take(&mut self.pending)
    }

    /// **Render ONE pane into the rect the caller has already decided** — the seam a
    /// `facett_app::scene` host needs to make a deck out of real panes with their own
    /// chrome. Mirrors `facett_buildthing::BuildThingDeck::view_pane` exactly.
    ///
    /// An out-of-range `idx` draws a visible marker rather than nothing: a blank rect
    /// reads as "this pane has no data", which is the confusion this whole crate is
    /// about.
    pub fn view_pane(&mut self, idx: usize, ui: &mut Ui) -> Vec<Msg> {
        match idx {
            PANE_KEY => self.ui_key(ui),
            PANE_REMOTES => self.ui_remotes(ui),
            PANE_LADDER => self.ui_ladder(ui),
            _ => {
                ui.weak(format!("no deck pane at index {idx} (deck holds {PANE_COUNT})"));
            }
        }
        self.take_msgs()
    }

    // ── pane 0: the key, the state, and where to paste it ────────────────────

    fn ui_key(&mut self, ui: &mut Ui) {
        let state = self.snap.state();
        ui.horizontal_wrapped(|ui| {
            chip(ui, state.as_str(), state_color(&state));
            if self.snap.probing {
                ui.spinner();
                ui.label(egui::RichText::new("probing…").small().weak());
            }
        });
        ui.label(egui::RichText::new(state.headline()).small());
        ui.separator();

        // The identity — what a human will see in Codeberg's deploy-key list.
        match self.snap.identity.as_deref() {
            Some(id) => {
                ui.horizontal_wrapped(|ui| {
                    ui.label(egui::RichText::new("identity").small().weak());
                    ui.label(egui::RichText::new(id).monospace().strong());
                    if self.snap.identity_is_stale() {
                        chip(ui, "STALE — says nornir", WARN);
                    }
                });
                if self.snap.identity_is_stale() {
                    ui.label(
                        egui::RichText::new(
                            "this box's only service is dwarves — nornir is compiled IN, not \
                             run separately. Renaming the identity is COSMETIC (the comment \
                             is not part of the key and not part of authentication): the key \
                             material is unchanged and nothing has to be re-registered.",
                        )
                        .small()
                        .weak(),
                    );
                    if ui.button("Rename identity to dwarves@<host>").clicked() {
                        self.pending.push(Msg::Relabel);
                    }
                }
            }
            None if self.snap.public_line.is_some() => {
                ui.label(
                    egui::RichText::new("identity: <none> — the key carries no comment")
                        .small()
                        .color(WARN),
                );
            }
            None => {}
        }

        // The public key + the copy action.
        match self.snap.public_line.clone() {
            Some(line) => {
                ui.add_space(4.0);
                ui.label(egui::RichText::new("public key — paste THIS").small().strong());
                // Selectable so a user can drag-select it even without the button, and
                // wrapped so a narrow pane never truncates the blob into a key that
                // silently does not work when pasted.
                ui.add(
                    egui::Label::new(egui::RichText::new(&line).monospace().small())
                        .wrap()
                        .sense(Sense::click()),
                );
                ui.horizontal_wrapped(|ui| {
                    if ui.button("📋 Copy public key").clicked() {
                        self.copy_buffer = Some(line.clone());
                        self.pending.push(Msg::Copy(line.clone()));
                    }
                    ui.label(
                        egui::RichText::new("(the PRIVATE half is never read or shown)")
                            .small()
                            .weak(),
                    );
                });
                ui.add_space(4.0);
                paste_guidance(ui);
            }
            None => {
                ui.add_space(4.0);
                ui.label(
                    egui::RichText::new(
                        "no public key to show — there is nothing to paste anywhere yet",
                    )
                    .small()
                    .color(RED),
                );
                if self.snap.resolution.chosen().is_some() {
                    ui.label(
                        egui::RichText::new(
                            "a PRIVATE key resolved but its `.pub` half is missing (a truncated \
                             install). That is not the same as having no key: the fetch will \
                             still authenticate — you just cannot read out what to register.",
                        )
                        .small()
                        .color(WARN),
                    );
                }
            }
        }

        // Minting — an explicit action, never a side effect of looking.
        if self.snap.resolution.chosen().is_none() {
            ui.add_space(6.0);
            ui.label(
                egui::RichText::new(
                    "dwarves can mint its own key at ~/.dwarves/ssh/id_ed25519 (ed25519, \
                     0600, pure Rust — no ssh-keygen). You will then need to register the \
                     public half; until you do, the state below reads MINTED — NOT \
                     REGISTERED, not 'working'.",
                )
                .small()
                .weak(),
            );
            if ui
                .button(egui::RichText::new("🔑 Mint dwarves' deploy key").strong())
                .clicked()
            {
                self.pending.push(Msg::Mint);
            }
        }

        if let Some(n) = &self.snap.notice {
            ui.add_space(4.0);
            ui.label(egui::RichText::new(n).small().monospace());
        }
    }

    // ── pane 1: what the remotes actually say ────────────────────────────────

    fn ui_remotes(&mut self, ui: &mut Ui) {
        ui.horizontal_wrapped(|ui| {
            if ui
                .add_enabled(!self.snap.probing, egui::Button::new("↻ Probe all remotes"))
                .clicked()
            {
                self.pending.push(Msg::ProbeAll);
            }
            if self.snap.probing {
                ui.spinner();
            }
            ui.label(
                egui::RichText::new("a real SSH ref advertisement, with this key")
                    .small()
                    .weak(),
            );
        });
        ui.separator();

        if self.snap.probes.is_empty() {
            // An EMPTY list is a claim about the world, so say which claim. "No repos
            // configured" and "repos exist, none listed here" are different bugs.
            ui.label(
                egui::RichText::new(
                    "no remotes known to this pane — that is an ABSENT repo list, not a \
                     clean bill of health. Nothing here has been checked.",
                )
                .small()
                .color(WARN),
            );
            return;
        }

        let mut probe_one: Option<String> = None;
        egui::ScrollArea::vertical().id_salt("dk-remotes").show(ui, |ui| {
            for p in &self.snap.probes {
                ui.horizontal_wrapped(|ui| {
                    chip(ui, p.outcome.as_str(), outcome_color(&p.outcome));
                    if ui
                        .add(
                            egui::Label::new(egui::RichText::new(&p.remote).monospace().small())
                                .sense(Sense::click()),
                        )
                        .on_hover_text("click to probe just this remote")
                        .clicked()
                    {
                        probe_one = Some(p.remote.clone());
                    }
                    if p.elapsed_ms > 0 {
                        ui.label(
                            egui::RichText::new(format!("{} ms", p.elapsed_ms)).small().weak(),
                        );
                    }
                });
                // The compiler's / server's own words, verbatim. An enum groups; this
                // is what someone actually fixes from.
                if !p.detail.is_empty() {
                    let col = if p.outcome.is_green() { None } else { Some(outcome_color(&p.outcome)) };
                    let t = egui::RichText::new(format!("    {}", p.detail)).small().monospace();
                    ui.label(if let Some(c) = col { t.color(c) } else { t.weak() });
                }
            }
        });
        if let Some(r) = probe_one {
            self.pending.push(Msg::Probe(r));
        }
    }

    // ── pane 2: which rung of the ladder answered ────────────────────────────

    fn ui_ladder(&mut self, ui: &mut Ui) {
        let res = &self.snap.resolution;
        match res.chosen_origin() {
            Some(o) => {
                ui.horizontal_wrapped(|ui| {
                    ui.label(egui::RichText::new("resolved via").small().weak());
                    chip(ui, o.as_str(), if o.is_legacy() { WARN } else { TEAL });
                    if o.is_escape_hatch() {
                        chip(ui, "ENV OVERRIDE", WARN);
                    }
                });
                ui.label(egui::RichText::new(o.explain()).small().weak());
                if o.is_legacy() {
                    ui.label(
                        egui::RichText::new(
                            "this is a nornir-era path, kept working on purpose. The default \
                             needs no configuration: ~/.dwarves/ssh/id_ed25519.",
                        )
                        .small()
                        .color(WARN),
                    );
                }
            }
            None => {
                ui.label(
                    egui::RichText::new("NO rung answered — every path below was empty")
                        .small()
                        .color(RED),
                );
            }
        }
        ui.separator();
        ui.label(
            egui::RichText::new("every path that was looked at, in order:").small().weak(),
        );
        egui::ScrollArea::vertical().id_salt("dk-ladder").show(ui, |ui| {
            for (i, c) in res.candidates.iter().enumerate() {
                let won = res.chosen == Some(i);
                ui.horizontal_wrapped(|ui| {
                    if won {
                        chip(ui, "USED", TEAL);
                    } else if c.present {
                        // A key sitting on a rung that did NOT win is worth seeing: it
                        // is the shape of "you edited the wrong file".
                        chip(ui, "present, outranked", WARN);
                    } else {
                        chip(ui, "absent", GREY);
                    }
                    ui.label(egui::RichText::new(c.origin.as_str()).small().strong());
                });
                ui.label(
                    egui::RichText::new(format!("    {}", c.private.display()))
                        .small()
                        .monospace()
                        .weak(),
                );
            }
        });
    }

    // ── the single-body path: three panes, two draggable dividers ────────────

    /// Draw all three panes stacked with draggable dividers. Used only when a host
    /// mounts the deck as ONE tab body; a scene host calls
    /// [`view_pane`](Self::view_pane) per pane instead and gets the scene's own
    /// splitters, chrome, fold and maximise.
    fn ui_all(&mut self, ui: &mut Ui) {
        let total = ui.available_height().max(160.0);
        let mut heights = [total * self.ratios[0], total * self.ratios[1]];
        for idx in 0..PANE_COUNT {
            let title = self.pane_title(idx).unwrap_or_default();
            ui.label(egui::RichText::new(title).small().strong());
            // The two resizable panes have a height; the last one takes the rest.
            if let Some(height) = heights.get_mut(idx) {
                let h = height.clamp(60.0, total - 80.0);
                egui::ScrollArea::vertical()
                    .id_salt(("dk-body", idx))
                    .max_height(h)
                    .show(ui, |ui| {
                        ui.set_min_height(h);
                        self.view_pane_body(idx, ui);
                    });
                if let Some(dy) = divider(ui) {
                    let f = (self.ratios[idx] + dy / total).clamp(0.12, 0.70);
                    self.ratios[idx] = f;
                    *height = total * f;
                }
            } else {
                egui::ScrollArea::vertical().id_salt(("dk-body", idx)).show(ui, |ui| {
                    self.view_pane_body(idx, ui);
                });
            }
        }
    }

    /// The pane body without the `take_msgs` drain (the single-body path drains once
    /// at the end, via `Facet::ui`'s caller).
    fn view_pane_body(&mut self, idx: usize, ui: &mut Ui) {
        match idx {
            PANE_KEY => self.ui_key(ui),
            PANE_REMOTES => self.ui_remotes(ui),
            PANE_LADDER => self.ui_ladder(ui),
            _ => {}
        }
    }

    /// The observable state — LAW 6 / FC-6. Everything a headless driver needs to
    /// assert that the five states render as five, including the counts that make a
    /// partial green legible.
    pub fn state_json(&self) -> serde_json::Value {
        let st = self.snap.state();
        let probed: Vec<&RemoteProbe> = self
            .snap
            .probes
            .iter()
            .filter(|p| p.outcome != ProbeOutcome::NotProbed)
            .collect();
        serde_json::json!({
            "state": st,
            "state_label": st.as_str(),
            "headline": st.headline(),
            // Deliberately NOT `state == Fetching`: `is_green` is false for every
            // state in which nothing was measured.
            "green": st.is_green(),
            "identity": self.snap.identity,
            "identity_stale": self.snap.identity_is_stale(),
            "has_public_key": self.snap.public_line.is_some(),
            // The public key IS publishable — that is its entire job. The private
            // path appears as a PATH; its bytes are never read by this crate.
            "public_line": self.snap.public_line,
            "private_path": self.snap.resolution.private_path().map(|p| p.display().to_string()),
            "origin": self.snap.resolution.chosen_origin().map(|o| o.as_str()),
            "origin_is_legacy": self.snap.resolution.is_legacy_fallback(),
            "origin_is_env_override": self.snap.resolution.chosen_origin().is_some_and(KeyOrigin::is_escape_hatch),
            "ladder": self.snap.resolution.candidates.iter().map(|c| serde_json::json!({
                "origin": c.origin.as_str(),
                "path": c.private.display().to_string(),
                "present": c.present,
            })).collect::<Vec<_>>(),
            "remotes_known": self.snap.probes.len(),
            "remotes_probed": probed.len(),
            "remotes_ok": probed.iter().filter(|p| p.outcome.is_green()).count(),
            "remotes_rejected": probed.iter().filter(|p| p.outcome == ProbeOutcome::AuthRejected).count(),
            "remotes_unreachable": probed.iter().filter(|p| p.outcome == ProbeOutcome::Unreachable).count(),
            "minted_this_session": self.snap.minted_this_session,
            "probing": self.snap.probing,
        })
    }
}

fn paste_guidance(ui: &mut Ui) {
    ui.label(egui::RichText::new("where to paste it").small().strong());
    ui.label(
        egui::RichText::new(
            "• PREFER a per-repo DEPLOY KEY. Codeberg: repo → Settings → Deploy Keys → \
             Add Deploy Key. GitHub: repo → Settings → Deploy keys → Add deploy key. \
             Leave write access OFF — the forge only fetches. A deploy key is scoped to \
             ONE repository, so a leaked forge key cannot touch anything else you own.",
        )
        .small(),
    );
    ui.label(
        egui::RichText::new(
            "• An ACCOUNT SSH key (Codeberg/GitHub → Settings → SSH keys) works for every \
             repo at once and is the reason you would only need to paste this in one \
             place — which is also exactly why it is the riskier choice: it carries your \
             whole account, not one repo.",
        )
        .small(),
    );
    ui.label(
        egui::RichText::new(
            "• A plain SSH server: append the line to ~/.ssh/authorized_keys of the \
             account the forge logs in as.",
        )
        .small(),
    );
    ui.label(
        egui::RichText::new(
            "Codeberg rejects the SAME deploy key on two repositories by default — add it \
             per repo, or use one account key.",
        )
        .small()
        .weak(),
    );
}

/// A draggable horizontal divider; returns the vertical drag delta when moved.
fn divider(ui: &mut Ui) -> Option<f32> {
    let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), Sense::drag());
    let hot = resp.hovered() || resp.dragged();
    let col = if hot { ui.visuals().selection.bg_fill } else { ui.visuals().weak_text_color() };
    ui.painter().rect_filled(rect.shrink2(egui::vec2(0.0, 2.0)), 2.0, col);
    if hot {
        ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
    }
    resp.dragged().then(|| resp.drag_delta().y)
}

const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
/// **A rejection is not an absence.** `NoKeyFound` and `RejectedByRemote` are both
/// bad and both used to be plain RED — which made two states that need two different
/// fixes (mint one vs. register the one you have) look like one state. The colour
/// test below is what caught it.
const REJECT: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x0a);
const WARN: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);
/// "Nobody asked" — its OWN colour, never green and never red. A never-probed row
/// that borrowed either colour would be making a claim nobody measured.
const GREY: egui::Color32 = egui::Color32::from_rgb(0x45, 0x4a, 0x4d);
const BLUE: egui::Color32 = egui::Color32::from_rgb(0x2e, 0x50, 0x6a);

fn state_color(s: &KeyState) -> egui::Color32 {
    match s {
        KeyState::NoKeyFound => RED,
        KeyState::RejectedByRemote { .. } => REJECT,
        KeyState::MintedNotRegistered => BLUE,
        // Unknown is unknown. Not green, not red.
        KeyState::PresentNeverUsed => GREY,
        KeyState::Fetching { failing: 0, .. } => TEAL,
        KeyState::Fetching { .. } => WARN,
    }
}

fn outcome_color(o: &ProbeOutcome) -> egui::Color32 {
    match o {
        ProbeOutcome::Authenticated { .. } => TEAL,
        ProbeOutcome::AuthRejected => RED,
        ProbeOutcome::Unreachable => WARN,
        ProbeOutcome::NoKey => RED,
        ProbeOutcome::NotProbed => GREY,
    }
}

use facett_core::look::chip;

/// **`Facet` only** — facett-core's blanket `impl<T: Facet> Panel for T` supplies
/// `Panel`, so there is nothing to disambiguate at a call site. Implementing both by
/// hand is what produced `E0034` on `ui` / `update_json` in dwarves' pane.
impl Facet for DeployKeyDeck {
    fn title(&self) -> &str {
        "Deploy key"
    }

    fn ui(&mut self, ui: &mut Ui) {
        self.ui_all(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        DeployKeyDeck::state_json(self)
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if v.get("probe_all").is_some() {
            self.pending.push(Msg::ProbeAll);
        }
        if let Some(r) = v.get("probe").and_then(|x| x.as_str()) {
            self.pending.push(Msg::Probe(r.to_string()));
        }
        if v.get("mint").is_some() {
            self.pending.push(Msg::Mint);
        }
        if v.get("relabel").is_some() {
            self.pending.push(Msg::Relabel);
        }
        if v.get("copy").is_some() && let Some(line) = self.snap.public_line.clone() {
            self.copy_buffer = Some(line.clone());
            self.pending.push(Msg::Copy(line));
        }
        if let Some(r) = v.get("set_ratio").and_then(|x| x.as_array())
            && let (Some(i), Some(f)) =
                (r.first().and_then(|x| x.as_u64()), r.get(1).and_then(|x| x.as_f64()))
            && (i as usize) < self.ratios.len()
        {
            self.ratios[i as usize] = (f as f32).clamp(0.12, 0.70);
        }
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps { copyable: true, resizable: true, themeable: true, ..FacetCaps::NONE }
    }

    /// Ctrl-C yields the PUBLIC key — the one thing on this pane whose whole purpose
    /// is to be copied elsewhere. There is no branch here that can reach the private
    /// half: this deck never reads it.
    fn copy(&mut self) -> Option<String> {
        self.copy_buffer.clone().or_else(|| self.snap.public_line.clone())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use nornir_git::deploykey::{KeyCandidate, KeyOrigin};
    use std::path::PathBuf;

    fn res_with_key(origin: KeyOrigin) -> Resolution {
        let dir = PathBuf::from("/x/ssh");
        Resolution {
            candidates: vec![KeyCandidate {
                origin,
                dir: dir.clone(),
                private: dir.join("id_ed25519"),
                public: dir.join("id_ed25519.pub"),
                present: true,
            }],
            chosen: Some(0),
            // A single-rung resolution is what a CONFINED ladder produces, and
            // this helper builds exactly one rung on purpose — it is testing how
            // one origin renders, not how a ladder resolves.
            confined: true,
        }
    }

    fn probe(remote: &str, outcome: ProbeOutcome, detail: &str) -> RemoteProbe {
        RemoteProbe {
            remote: remote.into(),
            outcome,
            detail: detail.into(),
            elapsed_ms: 7,
        }
    }

    fn deck(snap: KeySnapshot) -> DeployKeyDeck {
        DeployKeyDeck::new(snap)
    }

    /// **THE requirement.** The four states the pane must not collapse render as four
    /// distinguishable states in `state_json` — different label, different headline,
    /// and only one of them green.
    #[test]
    fn the_four_states_render_as_four_distinguishable_things() {
        let no_key = deck(KeySnapshot::default());
        let never_used = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            probes: vec![probe("ssh://git@git.gunnar.rs:2222/nordisk/nornir.git", ProbeOutcome::NotProbed, "")],
            ..Default::default()
        });
        let rejected = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            probes: vec![probe(
                "ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
                ProbeOutcome::AuthRejected,
                "ssh publickey auth rejected for git@git.gunnar.rs",
            )],
            ..Default::default()
        });
        let fetching = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            probes: vec![probe(
                "ssh://git@git.gunnar.rs:2222/nordisk/nornir.git",
                ProbeOutcome::Authenticated { head: Some("abc".into()) },
                "84 ref(s) advertised",
            )],
            ..Default::default()
        });

        let decks = [&no_key, &never_used, &rejected, &fetching];
        let labels: Vec<String> = decks
            .iter()
            .map(|d| d.state_json()["state_label"].as_str().unwrap().to_string())
            .collect();
        let heads: Vec<String> = decks
            .iter()
            .map(|d| d.state_json()["headline"].as_str().unwrap().to_string())
            .collect();
        for i in 0..decks.len() {
            for j in (i + 1)..decks.len() {
                assert_ne!(labels[i], labels[j], "two states share a label: {labels:?}");
                assert_ne!(heads[i], heads[j], "two states share a headline");
                assert_ne!(
                    state_color(&decks[i].snap.state()),
                    state_color(&decks[j].snap.state()),
                    "two states share a colour: {} vs {}",
                    labels[i],
                    labels[j]
                );
            }
        }
        assert_eq!(labels[0], "NO KEY FOUND");
        assert_eq!(labels[1], "KEY PRESENT — NEVER USED");
        assert_eq!(labels[2], "KEY REJECTED BY REMOTE");
        assert_eq!(labels[3], "FETCHING");

        // Exactly ONE of them is green, and it is the one where a remote answered.
        let greens: Vec<bool> =
            decks.iter().map(|d| d.state_json()["green"].as_bool().unwrap()).collect();
        assert_eq!(greens, vec![false, false, false, true]);
    }

    /// A remote that has never been probed is counted as KNOWN but NOT PROBED, and
    /// contributes to neither the ok nor the rejected tally. `remotes_probed` is the
    /// denominator that makes an all-quiet fleet legible instead of vacuously fine.
    #[test]
    fn a_never_probed_remote_is_not_a_passing_remote() {
        let d = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            probes: (0..8)
                .map(|i| probe(&format!("ssh://git@git.gunnar.rs:2222/nordisk/r{i}.git"), ProbeOutcome::NotProbed, ""))
                .collect(),
            ..Default::default()
        });
        let s = d.state_json();
        assert_eq!(s["remotes_known"], 8);
        assert_eq!(s["remotes_probed"], 0);
        assert_eq!(s["remotes_ok"], 0);
        assert_eq!(s["green"], false);
        assert_eq!(s["state_label"], "KEY PRESENT — NEVER USED");
    }

    /// The exact 2026-07-22 incident shape: eight repos rejected beside eleven that
    /// authenticate. The pane must report BOTH numbers and must not be green.
    #[test]
    fn eight_failing_beside_eleven_good_is_not_green() {
        let mut probes: Vec<RemoteProbe> = (0..11)
            .map(|i| {
                probe(
                    &format!("ssh://git@git.gunnar.rs:2222/nordisk/ok{i}.git"),
                    ProbeOutcome::Authenticated { head: None },
                    "refs advertised",
                )
            })
            .collect();
        probes.extend((0..8).map(|i| {
            probe(
                &format!("ssh://git@git.gunnar.rs:2222/nordisk/bad{i}.git"),
                ProbeOutcome::AuthRejected,
                "SSH remote needs a deploy key, but none was found",
            )
        }));
        let d = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            probes,
            ..Default::default()
        });
        let s = d.state_json();
        assert_eq!(s["remotes_ok"], 11);
        assert_eq!(s["remotes_rejected"], 8);
        assert_eq!(s["green"], false, "a partial green is not a green");
        assert!(s["headline"].as_str().unwrap().contains('8'));
    }

    /// The fifth state: minted-here-and-never-registered is its own thing, because a
    /// key nobody has been told about fails exactly like a key that does not exist.
    #[test]
    fn a_freshly_minted_key_says_it_is_not_registered_yet() {
        let d = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            minted_this_session: true,
            ..Default::default()
        });
        let s = d.state_json();
        assert_eq!(s["state_label"], "MINTED — NOT REGISTERED");
        assert_eq!(s["green"], false);
        assert_ne!(s["state_label"], "NO KEY FOUND");
        assert_ne!(s["state_label"], "KEY PRESENT — NEVER USED");
    }

    /// Which rung answered is observable — a legacy or env-override resolution is a
    /// FLAG a driver can assert on, not a thing a human has to infer.
    #[test]
    fn the_pane_says_which_rung_answered() {
        let d = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::NornirHomeLegacy),
            public_line: Some("ssh-ed25519 AAAA nornir@oden".into()),
            identity: Some("nornir@oden".into()),
            ..Default::default()
        });
        let s = d.state_json();
        assert_eq!(s["origin"], "~/.nornir/ssh (legacy)");
        assert_eq!(s["origin_is_legacy"], true);
        assert_eq!(s["origin_is_env_override"], false);
        assert_eq!(s["identity"], "nornir@oden");
        assert_eq!(s["identity_stale"], true, "the identity still names the old service");

        let env = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::EnvNornirLegacy),
            ..Default::default()
        });
        assert_eq!(env.state_json()["origin_is_env_override"], true);

        let dflt = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            identity: Some("dwarves@oden".into()),
            public_line: Some("ssh-ed25519 AAAA dwarves@oden".into()),
            ..Default::default()
        });
        let s = dflt.state_json();
        assert_eq!(s["origin_is_legacy"], false);
        assert_eq!(s["origin_is_env_override"], false);
        assert_eq!(s["identity_stale"], false);
    }

    /// Copy yields the PUBLIC key and there is no path by which it yields anything
    /// else — the deck holds no private material to leak.
    #[test]
    fn copy_yields_the_public_key_only() {
        let mut d = deck(KeySnapshot {
            resolution: res_with_key(KeyOrigin::DwarvesHome),
            public_line: Some("ssh-ed25519 AAAAC3Nz dwarves@oden".into()),
            ..Default::default()
        });
        assert!(d.caps().copyable);
        assert_eq!(d.copy().as_deref(), Some("ssh-ed25519 AAAAC3Nz dwarves@oden"));
        Facet::update_json(&mut d, r#"{"copy":true}"#);
        assert_eq!(d.take_msgs(), vec![Msg::Copy("ssh-ed25519 AAAAC3Nz dwarves@oden".into())]);
        // state_json publishes the public line and the private PATH — never bytes.
        let s = d.state_json();
        assert!(s["public_line"].as_str().unwrap().starts_with("ssh-ed25519 "));
        assert_eq!(s["private_path"], "/x/ssh/id_ed25519");
        assert!(!s.to_string().contains("BEGIN OPENSSH PRIVATE KEY"));
    }

    /// An empty deck with NO key still has three named panes and says, in words, that
    /// nothing has been checked — a blank pane would read as "fine".
    #[test]
    fn an_empty_deck_says_nothing_was_checked_rather_than_nothing() {
        let d = deck(KeySnapshot::default());
        assert_eq!(d.pane_count(), 3);
        assert_eq!(d.pane_title(PANE_KEY).unwrap(), "Deploy key — NO KEY FOUND");
        assert_eq!(d.pane_title(PANE_REMOTES).unwrap(), "Remotes — 0/0 probed");
        assert!(d.pane_title(PANE_LADDER).is_some());
        assert!(d.pane_title(3).is_none());
        let s = d.state_json();
        assert_eq!(s["green"], false);
        assert_eq!(s["has_public_key"], false);
    }

    /// The messages the host acts on come back from `view_pane`, and an out-of-range
    /// index draws a marker instead of silently nothing.
    #[test]
    fn the_deck_drives_through_update_json() {
        let mut d = deck(KeySnapshot::default());
        Facet::update_json(&mut d, r#"{"mint":true}"#);
        Facet::update_json(&mut d, r#"{"probe_all":true}"#);
        Facet::update_json(&mut d, r#"{"probe":"ssh://git@git.gunnar.rs:2222/nordisk/nornir.git"}"#);
        Facet::update_json(&mut d, r#"{"relabel":true}"#);
        assert_eq!(
            d.take_msgs(),
            vec![
                Msg::Mint,
                Msg::ProbeAll,
                Msg::Probe("ssh://git@git.gunnar.rs:2222/nordisk/nornir.git".into()),
                Msg::Relabel
            ]
        );
    }
}