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

nordisk/nornir

src/viz/ops_tabs.rs

//! Server-backed clickable tabs that turn nornir CLI/gRPC verbs into viz
//! surfaces: 🔍 Search (BM25 + symbol lookup), 🚦 Gates (release gates + trace),
//! 📈 Bench (history charts), and the picker's workspace info + ⟳ Sync-now panel.
//!
//! All are **remote-only** (they drive the running `nornir-server` over the
//! existing gRPC RPCs via [`super::remote`]); in local-warehouse mode they show
//! a one-line hint instead. Calls run on the synchronous click (a private
//! current-thread runtime inside each `remote::*` fn) — fine for the
//! interaction rates here; results are cached in the state until re-run.

use eframe::egui::{self};

use super::action_log::{ActionLog, Kind};
use super::facett_theme::{GREEN, RED, Theme};
use super::remote;

/// Endpoint+token bundle handed to the ops tabs each frame (None in local mode).
#[derive(Clone)]
pub struct Server {
    pub endpoint: String,
    pub token: String,
}

fn local_hint(ui: &mut egui::Ui, what: &str) {
    ui.add_space(20.0);
    ui.label(format!(
        "{what} is server-backed — launch the viz against a running nornir-server \
         (NORNIR_SERVER=…) to use it."
    ));
}

// ── 🔍 Search ────────────────────────────────────────────────────────────────

#[derive(Default)]
pub struct SearchState {
    query: String,
    corpus: String,
    sym_repo: String,
    sym_query: String,
    vec_repo: String,
    vec_query: String,
    call_repo: String,
    call_name: String,
    call_to: String,
    hits: Option<Result<Vec<remote::Hit>, String>>,
    syms: Option<Result<Vec<remote::KnownSym>, String>>,
    vhits: Option<Result<Vec<remote::VecHit>, String>>,
    stats: Option<Result<(u64, Vec<(String, String)>), String>>,
    // (label, rows of "a → b  (file:line)")
    calls: Option<Result<(String, Vec<String>), String>>,
    theme: Theme,
}

impl SearchState {
    /// Re-skin this pane with a facett palette (broadcast from the picker).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Test-only injector for the `Index.Stats` result (no live server): seed the
    /// per-corpus doc totals exactly as the RPC would deliver them, so a headless
    /// drive can assert the LAW-2 `index_health` classification (an empty index
    /// reads `"empty"` with a populate hint; a populated one reads `"populated"`).
    #[doc(hidden)]
    pub fn inject_index_stats_for_test(&mut self, total: u64, by_corpus: Vec<(String, String)>) {
        self.stats = Some(Ok((total, by_corpus)));
    }

    pub fn draw(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspace: &str,
        log: &ActionLog,
    ) {
        let theme = self.theme;
        let Some(srv) = srv else {
            return local_hint(ui, "Search");
        };
        ui.horizontal(|ui| {
            ui.heading("🔍 Search");
            if ui
                .button("index stats")
                .on_hover_text("Index.Stats — corpus doc counts")
                .clicked()
            {
                log.push(Kind::Rpc, "Index.Stats");
                self.stats = Some(
                    remote::index_stats(&srv.endpoint, &srv.token, workspace)
                        .map_err(|e| format!("{e:#}")),
                );
            }
            if let Some(Ok((total, by))) = &self.stats {
                ui.label(format!(
                    "index: {total} docs [{}]",
                    by.iter()
                        .map(|(k, v)| format!("{k}={v}"))
                        .collect::<Vec<_>>()
                        .join(", ")
                ));
            } else if let Some(Err(e)) = &self.stats {
                ui.colored_label(RED, e);
            }
        });

        // BM25 full-text over the Tantivy corpora (Search.Query).
        ui.group(|ui| {
            ui.label("Full-text (BM25 over docs/code/bench/changelog/config)");
            ui.horizontal(|ui| {
                let go = ui.text_edit_singleline(&mut self.query).lost_focus()
                    && ui.input(|i| i.key_pressed(egui::Key::Enter));
                ui.label("corpus:");
                egui::ComboBox::from_id_salt("search_corpus")
                    .selected_text(if self.corpus.is_empty() {
                        "all".into()
                    } else {
                        self.corpus.clone()
                    })
                    .show_ui(ui, |ui| {
                        for c in ["", "docs", "code", "bench_history", "changelog", "config"] {
                            ui.selectable_value(
                                &mut self.corpus,
                                c.to_string(),
                                if c.is_empty() { "all" } else { c },
                            );
                        }
                    });
                if (ui.button("Search").clicked() || go) && !self.query.trim().is_empty() {
                    log.push(
                        Kind::Query,
                        format!("Search.Query q={:?} corpus={:?}", self.query, self.corpus),
                    );
                    let res = remote::search(
                        &srv.endpoint,
                        &srv.token,
                        &self.query,
                        &self.corpus,
                        "",
                        25,
                        workspace,
                    )
                    .map_err(|e| format!("{e:#}"));
                    // EMIT-DOCTRINE (emit-fallback): the fulltext search is a
                    // server round-trip whose outcome can't be read from outside —
                    // self-report a functional_status row so the matrix carries the
                    // honest run verdict (hit count on ok, the error on fail).
                    match &res {
                        Ok(hits) => nornir_testmatrix::functional_status(
                            "nornir/viz/search",
                            "search_fulltext_ran",
                            true,
                            &format!(
                                "q={:?} corpus={:?} → {} hit(s)",
                                self.query,
                                self.corpus,
                                hits.len()
                            ),
                        ),
                        Err(e) => nornir_testmatrix::functional_status(
                            "nornir/viz/search",
                            "search_fulltext_ran",
                            false,
                            e,
                        ),
                    }
                    self.hits = Some(res);
                }
            });
        });
        match &self.hits {
            Some(Ok(hits)) => {
                ui.label(format!("{} hit(s)", hits.len()));
                egui::ScrollArea::vertical()
                    .max_height(220.0)
                    .auto_shrink([false, false])
                    .id_salt("hits")
                    .show(ui, |ui| {
                        for h in hits {
                            ui.horizontal(|ui| {
                                ui.colored_label(
                                    theme.text_dim,
                                    format!("[{}/{}] {:.2}", h.corpus, h.repo, h.score),
                                );
                                ui.strong(if h.title.is_empty() {
                                    &h.path
                                } else {
                                    &h.title
                                });
                            });
                            if !h.snippet.is_empty() {
                                ui.label(
                                    egui::RichText::new(&h.snippet)
                                        .size(11.0)
                                        .color(theme.text_dim),
                                );
                            }
                            ui.separator();
                        }
                    });
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }

        ui.add_space(8.0);
        // Symbol lookup (Knowledge.SymbolLookup over symbol_facts).
        ui.group(|ui| {
            ui.label("Symbol lookup (item-name substring over symbol_facts)");
            ui.horizontal(|ui| {
                ui.label("repo:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.sym_repo)
                        .desired_width(120.0)
                        .hint_text("blank=all"),
                );
                let go = ui.text_edit_singleline(&mut self.sym_query).lost_focus()
                    && ui.input(|i| i.key_pressed(egui::Key::Enter));
                if (ui.button("Lookup").clicked() || go) && !self.sym_query.trim().is_empty() {
                    log.push(
                        Kind::Query,
                        format!(
                            "Knowledge.SymbolLookup q={:?} repo={:?}",
                            self.sym_query, self.sym_repo
                        ),
                    );
                    self.syms = Some(
                        remote::knowledge_lookup(
                            &srv.endpoint,
                            &srv.token,
                            &self.sym_repo,
                            &self.sym_query,
                            50,
                            workspace,
                        )
                        .map_err(|e| format!("{e:#}")),
                    );
                }
            });
        });
        match &self.syms {
            Some(Ok(syms)) => {
                ui.label(format!("{} symbol(s)", syms.len()));
                egui::ScrollArea::vertical()
                    .max_height(220.0)
                    .auto_shrink([false, false])
                    .id_salt("syms")
                    .show(ui, |ui| {
                        for s in syms {
                            ui.horizontal(|ui| {
                                ui.colored_label(
                                    theme.text_dim,
                                    format!("{} {}", s.visibility, s.item_kind),
                                );
                                ui.strong(&s.item_name);
                                ui.label(
                                    egui::RichText::new(format!("{}:{}", s.file, s.line))
                                        .monospace()
                                        .size(11.0),
                                );
                            });
                            if !s.signature.is_empty() {
                                ui.label(
                                    egui::RichText::new(&s.signature)
                                        .monospace()
                                        .size(11.0)
                                        .color(theme.text_dim),
                                );
                            }
                            ui.separator();
                        }
                    });
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }

        ui.add_space(8.0);
        // Semantic / vector search (Vector.Search — GPU on oden, CPU fallback).
        ui.group(|ui| {
            ui.label("Semantic (vector embeddings — meaning, not keywords)");
            ui.horizontal(|ui| {
                ui.label("repo:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.vec_repo)
                        .desired_width(120.0)
                        .hint_text("repo name"),
                );
                let go = ui.text_edit_singleline(&mut self.vec_query).lost_focus()
                    && ui.input(|i| i.key_pressed(egui::Key::Enter));
                if (ui.button("Semantic search").clicked() || go)
                    && !self.vec_query.trim().is_empty()
                    && !self.vec_repo.trim().is_empty()
                {
                    log.push(
                        Kind::Query,
                        format!(
                            "Vector.Search q={:?} repo={:?}",
                            self.vec_query, self.vec_repo
                        ),
                    );
                    self.vhits = Some(
                        remote::vector_search(
                            &srv.endpoint,
                            &srv.token,
                            &self.vec_repo,
                            &self.vec_query,
                            15,
                            workspace,
                        )
                        .map_err(|e| format!("{e:#}")),
                    );
                }
            });
        });
        match &self.vhits {
            Some(Ok(vh)) => {
                ui.label(format!("{} hit(s)", vh.len()));
                egui::ScrollArea::vertical()
                    .max_height(220.0)
                    .auto_shrink([false, false])
                    .id_salt("vhits")
                    .show(ui, |ui| {
                        for h in vh {
                            ui.horizontal(|ui| {
                                ui.colored_label(GREEN, format!("{:.3}", h.score));
                                ui.label(
                                    egui::RichText::new(format!(
                                        "{}:{}-{}",
                                        h.file, h.start_line, h.end_line
                                    ))
                                    .monospace()
                                    .size(11.0),
                                );
                            });
                        }
                    });
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }

        ui.add_space(8.0);
        // Call relationships (Knowledge.Callers / Callees / CallPath) — the
        // authoritative warehouse set, beyond the capped on-screen graph edges.
        ui.group(|ui| {
            ui.label("Call relationships (callers / callees / path between)");
            ui.horizontal(|ui| {
                ui.label("repo:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.call_repo)
                        .desired_width(110.0)
                        .hint_text("blank=all"),
                );
                ui.label("fn:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.call_name)
                        .desired_width(140.0)
                        .hint_text("function"),
                );
                if ui.button("callers").clicked() && !self.call_name.trim().is_empty() {
                    log.push(
                        Kind::Query,
                        format!(
                            "Knowledge.Callers fn={:?} repo={:?}",
                            self.call_name, self.call_repo
                        ),
                    );
                    self.calls = Some(self.run_calls(srv, workspace, true));
                }
                if ui.button("callees").clicked() && !self.call_name.trim().is_empty() {
                    log.push(
                        Kind::Query,
                        format!(
                            "Knowledge.Callees fn={:?} repo={:?}",
                            self.call_name, self.call_repo
                        ),
                    );
                    self.calls = Some(self.run_calls(srv, workspace, false));
                }
            });
            ui.horizontal(|ui| {
                ui.label("…→ to:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.call_to)
                        .desired_width(140.0)
                        .hint_text("target fn"),
                );
                if ui.button("path between").clicked()
                    && !self.call_name.trim().is_empty()
                    && !self.call_to.trim().is_empty()
                {
                    log.push(
                        Kind::Query,
                        format!(
                            "Knowledge.CallPath {:?} → {:?}",
                            self.call_name, self.call_to
                        ),
                    );
                    self.calls = Some(
                        remote::knowledge_call_path(
                            &srv.endpoint,
                            &srv.token,
                            &self.call_repo,
                            &self.call_name,
                            &self.call_to,
                            workspace,
                        )
                        .map(|p| {
                            (
                                if p.is_empty() {
                                    "no path found".into()
                                } else {
                                    format!("path ({} hops)", p.len())
                                },
                                p,
                            )
                        })
                        .map_err(|e| format!("{e:#}")),
                    );
                }
            });
        });
        match &self.calls {
            Some(Ok((label, rows))) => {
                ui.label(format!("{label}: {}", rows.len()));
                egui::ScrollArea::vertical()
                    .max_height(220.0)
                    .auto_shrink([false, false])
                    .id_salt("calls")
                    .show(ui, |ui| {
                        for row in rows {
                            ui.label(egui::RichText::new(row).monospace().size(11.0));
                        }
                    });
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }
    }

    fn run_calls(
        &self,
        srv: &Server,
        workspace: &str,
        callers: bool,
    ) -> Result<(String, Vec<String>), String> {
        remote::knowledge_calls(
            &srv.endpoint,
            &srv.token,
            &self.call_repo,
            &self.call_name,
            callers,
            100,
            workspace,
        )
        .map(|rows| {
            let label = if callers { "callers" } else { "callees" };
            let lines = rows
                .into_iter()
                .map(|(caller, callee, file, line)| format!("{caller} → {callee}  ({file}:{line})"))
                .collect();
            (label.to_string(), lines)
        })
        .map_err(|e| format!("{e:#}"))
    }

    /// 🔍 Search tab's slice of `state_json` (LAW #6): every form field the user
    /// can type into / pick (the BM25 query + corpus dropdown, the symbol/vector
    /// repo+query boxes, the call-graph repo/name/direction), PLUS the rendered
    /// results currently on screen (hit/symbol/vector counts, the index-stats
    /// totals, the call-path rows). So an agent reads back exactly what's typed
    /// and what's shown without a screen.
    pub fn state_json(&self) -> serde_json::Value {
        let result_state = |o: &Option<Result<usize, String>>| match o {
            None => serde_json::json!({ "ran": false }),
            Some(Ok(n)) => serde_json::json!({ "ran": true, "ok": true, "count": n }),
            Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
        };
        let hits = self
            .hits
            .as_ref()
            .map(|r| r.as_ref().map(|v| v.len()).map_err(|e| e.clone()));
        let syms = self
            .syms
            .as_ref()
            .map(|r| r.as_ref().map(|v| v.len()).map_err(|e| e.clone()));
        let vhits = self
            .vhits
            .as_ref()
            .map(|r| r.as_ref().map(|v| v.len()).map_err(|e| e.clone()));
        // LAW 2 (RAGNARÖK) — name the index's populated-state as DATA so an EMPTY
        // code-index (the classic FAT-workspace-symlink walker symptom) reads as a
        // flagged `"empty"` with a populate hint, never a silent green. A search
        // over an empty index returns 0 hits and "looks fine"; this field is what a
        // headless drive asserts so that empty goes RED instead of passing.
        let (index_health, populate_hint) = match &self.stats {
            None => ("not_run", serde_json::Value::Null),
            Some(Ok((0, _))) => (
                "empty",
                serde_json::json!(
                    "the code-index has 0 docs — run `nornir map`/populate; if members are \
                     symlinks confirm the walker follows links (follow_links=true) and the \
                     symlink targets are materialized"
                ),
            ),
            Some(Ok((_, _))) => ("populated", serde_json::Value::Null),
            Some(Err(_)) => ("error", serde_json::Value::Null),
        };
        serde_json::json!({
            "palette": self.theme.name,
            // LAW 2: the code-index's populated-state, surfaced for a headless assert
            // ("not_run" | "empty" | "populated" | "error"). "empty" is RAGNARÖK — a
            // data-bearing surface with zero data — and carries `index_populate_hint`.
            "index_health": index_health,
            "index_populate_hint": populate_hint,
            // The interactive form state — what's typed/selected right now.
            "form": {
                "query": self.query,
                "corpus": self.corpus,
                "sym_repo": self.sym_repo,
                "sym_query": self.sym_query,
                "vec_repo": self.vec_repo,
                "vec_query": self.vec_query,
                "call_repo": self.call_repo,
                "call_name": self.call_name,
                "call_to": self.call_to,
            },
            // The rendered results currently shown for each sub-panel.
            "results": {
                "fulltext": result_state(&hits),
                "symbols": result_state(&syms),
                "vectors": result_state(&vhits),
                "index_stats": match &self.stats {
                    None => serde_json::json!({ "ran": false }),
                    Some(Ok((total, by))) => serde_json::json!({
                        "ran": true, "ok": true, "total": total,
                        "by_corpus": by.iter().map(|(k, v)| serde_json::json!([k, v])).collect::<Vec<_>>(),
                    }),
                    Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
                },
                "calls": match &self.calls {
                    None => serde_json::json!({ "ran": false }),
                    Some(Ok((label, rows))) => serde_json::json!({
                        "ran": true, "ok": true, "label": label, "count": rows.len(), "rows": rows,
                    }),
                    Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
                },
            },
        })
    }
}

// ── 🚦 Gates ─────────────────────────────────────────────────────────────────

#[derive(Default)]
pub struct GatesState {
    repo: String,
    report: Option<Result<remote::GateReport, String>>,
    trace: Option<Result<String, String>>,
    theme: Theme,
}

impl GatesState {
    /// Re-skin this pane with a facett palette (broadcast from the picker).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    pub fn draw(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspace: &str,
        repos: &[String],
        log: &ActionLog,
    ) {
        let theme = self.theme;
        let Some(srv) = srv else {
            return local_hint(ui, "Gates");
        };
        ui.heading("🚦 Release gates");
        if self.repo.is_empty() {
            self.repo = repos.first().cloned().unwrap_or_default();
        }
        ui.horizontal(|ui| {
            ui.label("repo:");
            egui::ComboBox::from_id_salt("gate_repo")
                .selected_text(if self.repo.is_empty() {
                    "—".into()
                } else {
                    self.repo.clone()
                })
                .show_ui(ui, |ui| {
                    for r in repos {
                        ui.selectable_value(&mut self.repo, r.clone(), r);
                    }
                });
            // Allow a free-typed repo when the config has none listed.
            ui.add(
                egui::TextEdit::singleline(&mut self.repo)
                    .desired_width(160.0)
                    .hint_text("repo name"),
            );
            if ui.button("Run all gates").clicked() && !self.repo.trim().is_empty() {
                log.push(Kind::Rpc, format!("Release.GateAll repo={}", self.repo));
                self.report = Some(
                    remote::gate_all(&srv.endpoint, &srv.token, &self.repo, workspace)
                        .map_err(|e| format!("{e:#}")),
                );
            }
            if ui.button("Trace regression").clicked() && !self.repo.trim().is_empty() {
                log.push(Kind::Rpc, format!("Release.Trace repo={}", self.repo));
                self.trace = Some(
                    remote::trace(&srv.endpoint, &srv.token, &self.repo, workspace)
                        .map_err(|e| format!("{e:#}")),
                );
            }
        });
        ui.separator();
        match &self.report {
            Some(Ok(r)) => {
                ui.colored_label(GREEN, format!("✓ passed ({})", r.passed.len()));
                for p in &r.passed {
                    ui.colored_label(theme.text_dim, format!("   ✓ {p}"));
                }
                if !r.failed.is_empty() {
                    ui.colored_label(RED, format!("✗ failed ({})", r.failed.len()));
                    for (name, err) in &r.failed {
                        ui.colored_label(RED, format!("   ✗ {name}: {err}"));
                    }
                } else {
                    ui.colored_label(GREEN, "all gates green — releasable");
                }
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }
        if let Some(tr) = &self.trace {
            ui.separator();
            ui.label("regression trace:");
            egui::ScrollArea::vertical()
                .max_height(240.0)
                .auto_shrink([false, false])
                .id_salt("trace")
                .show(ui, |ui| match tr {
                    Ok(json) => {
                        let pretty = serde_json::from_str::<serde_json::Value>(json)
                            .ok()
                            .and_then(|v| serde_json::to_string_pretty(&v).ok())
                            .unwrap_or_else(|| json.clone());
                        ui.add(egui::Label::new(
                            egui::RichText::new(pretty).monospace().size(11.0),
                        ));
                    }
                    Err(e) => {
                        ui.colored_label(RED, e);
                    }
                });
        }
    }

    /// 🚦 Gates tab's slice of `state_json` (LAW #6): the selected/typed repo
    /// (the picker+free-text field) and the rendered gate report (passed/failed
    /// gate names) + whether a regression trace is loaded.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "palette": self.theme.name,
            "form": { "repo": self.repo },
            "report": match &self.report {
                None => serde_json::json!({ "ran": false }),
                Some(Ok(r)) => serde_json::json!({
                    "ran": true, "ok": true,
                    "passed": r.passed,
                    "failed": r.failed.iter().map(|(n, e)| serde_json::json!([n, e])).collect::<Vec<_>>(),
                    "releasable": r.failed.is_empty(),
                }),
                Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
            },
            "trace": match &self.trace {
                None => serde_json::json!({ "ran": false }),
                Some(Ok(_)) => serde_json::json!({ "ran": true, "ok": true }),
                Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
            },
        })
    }
}

// ── workspace info + ⟳ Sync-now (rendered inline near the picker) ────────────

/// AUT6 (n-003) per-member working-tree staleness, derived from the
/// `Workspaces.Get` reading the viz client holds: `(dirty, digest)` of the
/// **server's** clone (the state the warehouse was indexed from).
///
/// The viz thin-client has no local checkout to diff against, so the
/// `local_uncommitted` state the MCP `index_status` handler reports (live local
/// tree vs indexed digest) is not derivable here; the two states this surface
/// CAN distinguish are:
///   * [`Staleness::Fresh`]       — server clone clean (digest = clean sentinel / empty, not dirty)
///   * [`Staleness::ServerDirty`] — server clone has uncommitted changes (advisory)
///
/// Matches the `None`-local branch of the `index_status` staleness ladder in
/// `src/bin/nornir-mcp.rs`, so the viz badge and the MCP signal agree.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Staleness {
    Fresh,
    ServerDirty,
}

impl Staleness {
    /// Derive the staleness of a member from the `(dirty, digest)` carried in
    /// [`remote::WorkspaceInfo::freshness`].
    pub fn of(dirty: bool, _digest: &str) -> Self {
        // `dirty` is the authoritative flag; the digest is advisory context
        // (carried for display only — a clean clone has the CLEAN_WORKTREE_DIGEST
        // sentinel or an empty never-computed digest, both treated as fresh).
        if dirty {
            Staleness::ServerDirty
        } else {
            Staleness::Fresh
        }
    }

    /// Stable token for `state_json` (matches the `index_status` vocabulary).
    pub fn as_str(self) -> &'static str {
        match self {
            Staleness::Fresh => "fresh",
            Staleness::ServerDirty => "server_dirty",
        }
    }

    /// The on-screen badge glyph+label for this state (empty for fresh members).
    pub fn badge(self) -> &'static str {
        match self {
            Staleness::Fresh => "",
            Staleness::ServerDirty => " ⚠ server-dirty (uncommitted)",
        }
    }
}

#[derive(Default)]
pub struct WorkspacePanel {
    open: bool,
    info: Option<Result<remote::WorkspaceInfo, String>>,
    info_for: String,
    sync_result: Option<Result<(u32, Vec<String>, Vec<String>, String), String>>,
}

impl WorkspacePanel {
    /// Compact controls for the top bar: ℹ toggle + ⟳ Sync now.
    ///
    /// Returns `true` when a sync was just triggered, so the caller can **reload
    /// the displayed timeline/warehouse immediately** — otherwise a server-side
    /// republish wouldn't show until the next 5 s auto-reload tick.
    #[must_use]
    pub fn draw_controls(
        &mut self,
        ui: &mut egui::Ui,
        srv: Option<&Server>,
        workspace: &str,
    ) -> bool {
        let Some(srv) = srv else { return false };
        if ui.selectable_label(self.open, "ℹ info").clicked() {
            self.open = !self.open;
            if self.open {
                self.refresh(srv, workspace);
            }
        }
        let mut synced = false;
        if ui
            .button("⟳ Sync now")
            .on_hover_text(
                "Workspaces.Fetch (force) — poll the remote(s) and rebuild this \
                 workspace's warehouse now, then reload the view",
            )
            .clicked()
        {
            // force=true: republish even if no git member changed (e.g. the
            // warehouse was cleared) so it regenerates on demand, not on the poll.
            self.sync_result = Some(
                remote::fetch_workspace(&srv.endpoint, &srv.token, workspace, true)
                    .map_err(|e| format!("{e:#}")),
            );
            self.refresh(srv, workspace); // refresh shown SHAs after a sync
            synced = true;
        }
        synced
    }

    fn refresh(&mut self, srv: &Server, workspace: &str) {
        self.info = Some(
            remote::get_workspace(&srv.endpoint, &srv.token, workspace)
                .map_err(|e| format!("{e:#}")),
        );
        self.info_for = workspace.to_string();
    }

    /// Test-only: seed the resolved [`remote::WorkspaceInfo`] directly (open the
    /// panel) so the inject-assert harness can drive `state_json`/`draw_panel`
    /// without a live server. Mirrors `BenchState::inject_live_for_test`.
    #[doc(hidden)]
    pub fn inject_info_for_test(&mut self, info: remote::WorkspaceInfo) {
        self.open = true;
        self.info_for = info.name.clone();
        self.info = Some(Ok(info));
    }

    /// Readable state for the headless matrix (the "components expose what they
    /// render" law). Includes the AUT6 per-member working-tree freshness:
    /// `{ member: { dirty, digest, staleness, badge } }` plus a workspace-level
    /// `any_dirty` flag, so a test can assert the **distinct** staleness signal
    /// (`fresh` vs `server_dirty`) and its rendered badge without scraping pixels.
    pub fn state_json(&self) -> serde_json::Value {
        match &self.info {
            Some(Ok(i)) => {
                let freshness: serde_json::Map<String, serde_json::Value> = i
                    .freshness
                    .iter()
                    .map(|(name, dirty, digest)| {
                        let st = Staleness::of(*dirty, digest);
                        (
                            name.clone(),
                            serde_json::json!({
                                "dirty": dirty,
                                "digest": digest,
                                "staleness": st.as_str(),
                                "badge": st.badge(),
                            }),
                        )
                    })
                    .collect();
                let any_dirty = i.freshness.iter().any(|(_, d, _)| *d);
                serde_json::json!({
                    "open": self.open,
                    "workspace": i.name,
                    "snapshot": i.current_snapshot,
                    "freshness": freshness,
                    "any_dirty": any_dirty,
                })
            }
            Some(Err(e)) => serde_json::json!({ "open": self.open, "error": e }),
            None => serde_json::json!({ "open": self.open }),
        }
    }

    /// The expandable detail panel (rendered in the side panel when `open`).
    pub fn draw_panel(&mut self, ui: &mut egui::Ui, srv: Option<&Server>, workspace: &str) {
        if !self.open || srv.is_none() {
            return;
        }
        if self.info_for != workspace {
            self.refresh(srv.unwrap(), workspace);
        }
        ui.separator();
        ui.strong("workspace");
        match &self.info {
            Some(Ok(i)) => {
                ui.label(format!("{} · {}", i.name, i.mode));
                ui.label(format!("poll: {}", i.poll));
                ui.label(format!("snapshot: {}", short(&i.current_snapshot)));
                ui.label(format!("updated: {}", i.updated_at));
                ui.label(format!("members ({}):", i.members.len()));
                for (name, summary) in &i.members {
                    // The summary string already carries the legacy " ⚠ uncommitted"
                    // marker from get_workspace; keep it. ALSO surface the distinct
                    // AUT6 staleness state (fresh vs server_dirty) as a coloured
                    // badge so the signal is prominent (not just inside the text).
                    let staleness = i
                        .freshness
                        .iter()
                        .find(|(n, _, _)| n == name)
                        .map(|(_, dirty, digest)| Staleness::of(*dirty, digest));
                    ui.horizontal(|ui| {
                        ui.label(egui::RichText::new(format!("  {name}: {summary}")).size(11.0));
                        match staleness {
                            Some(Staleness::ServerDirty) => {
                                ui.colored_label(
                                    super::facett_theme::AMBER,
                                    egui::RichText::new(Staleness::ServerDirty.badge()).size(11.0),
                                );
                            }
                            Some(Staleness::Fresh) => {
                                ui.colored_label(GREEN, egui::RichText::new(" ✓ fresh").size(11.0));
                            }
                            None => {}
                        }
                    });
                }
            }
            Some(Err(e)) => {
                ui.colored_label(RED, e);
            }
            None => {}
        }
        if let Some(sync) = &self.sync_result {
            ui.separator();
            match sync {
                Ok((fetched, changed, errors, snapshot)) => {
                    let snap = if snapshot.is_empty() {
                        String::new()
                    } else {
                        format!(" → snapshot {}", short(snapshot))
                    };
                    ui.colored_label(
                        GREEN,
                        format!("synced: {fetched} fetched, {} changed{snap}", changed.len()),
                    );
                    for e in errors {
                        ui.colored_label(RED, format!("  {e}"));
                    }
                }
                Err(e) => {
                    ui.colored_label(RED, e);
                }
            }
        }
    }
}

#[cfg(test)]
mod staleness_tests {
    use super::*;

    /// AUT6 (n-003) staleness derivation: a clean reading is `fresh`, a dirty
    /// reading is `server_dirty`, regardless of the advisory digest value.
    #[test]
    fn staleness_of_distinguishes_fresh_and_server_dirty() {
        assert_eq!(
            Staleness::of(false, crate::gitio::CLEAN_WORKTREE_DIGEST),
            Staleness::Fresh
        );
        assert_eq!(Staleness::of(false, ""), Staleness::Fresh);
        assert_eq!(Staleness::of(true, "deadbeef"), Staleness::ServerDirty);
        // Tokens match the index_status vocabulary.
        assert_eq!(Staleness::Fresh.as_str(), "fresh");
        assert_eq!(Staleness::ServerDirty.as_str(), "server_dirty");
        // The fresh badge is empty; the dirty one carries a warning glyph.
        assert_eq!(Staleness::Fresh.badge(), "");
        assert!(Staleness::ServerDirty.badge().contains('⚠'));
    }

    /// LAW (inject-assert): construct a `WorkspacePanel` with KNOWN freshness
    /// (one clean member + one dirty member), derive `state_json`, and assert the
    /// rendered state reflects the DISTINCT staleness states — the dirty member
    /// shows `server_dirty` + a ⚠ badge, the clean one shows `fresh` + no badge,
    /// and the workspace-level `any_dirty` flag is set. Real input, real output.
    #[test]
    fn workspace_panel_state_json_surfaces_distinct_staleness() {
        let info = remote::WorkspaceInfo {
            name: "ws-test".into(),
            mode: "monitored".into(),
            poll: "30s".into(),
            current_snapshot: "snap-001".into(),
            updated_at: "2026-06-15T00:00:00Z".into(),
            members: vec![
                ("clean_repo".into(), "origin @ abc12345 [synced]".into()),
                (
                    "dirty_repo".into(),
                    "origin @ def67890 [synced] ⚠ uncommitted".into(),
                ),
            ],
            freshness: vec![
                (
                    "clean_repo".into(),
                    false,
                    crate::gitio::CLEAN_WORKTREE_DIGEST.to_string(),
                ),
                ("dirty_repo".into(), true, "9f8e7d6c".to_string()),
            ],
        };

        let mut panel = WorkspacePanel::default();
        panel.inject_info_for_test(info);

        let s = panel.state_json();
        assert_eq!(s["workspace"], "ws-test");
        assert_eq!(s["any_dirty"], true, "a dirty member ⇒ workspace any_dirty");

        let fresh = &s["freshness"]["clean_repo"];
        assert_eq!(fresh["dirty"], false);
        assert_eq!(fresh["staleness"], "fresh", "clean member ⇒ fresh");
        assert_eq!(fresh["badge"], "", "fresh member renders no warning badge");

        let dirty = &s["freshness"]["dirty_repo"];
        assert_eq!(dirty["dirty"], true);
        assert_eq!(
            dirty["staleness"], "server_dirty",
            "dirty member ⇒ server_dirty"
        );
        assert_ne!(
            dirty["staleness"], fresh["staleness"],
            "the two states are distinct"
        );
        let badge = dirty["badge"].as_str().expect("badge is a string");
        assert!(
            badge.contains('⚠'),
            "dirty member renders a ⚠ badge, got {badge:?}"
        );
        assert!(
            badge.contains("server-dirty"),
            "dirty badge distinguishes server-dirty (not the collapsed legacy marker), got {badge:?}"
        );
    }

    /// **RAGNARÖK (LAW 2) for the 🔍 Search tab — the walker-bug symptom is named,
    /// not silently green.** An empty code-index (the classic FAT-workspace symlink
    /// walker failure) makes every search return 0 hits and "look fine". This proves
    /// the Search tab classifies its index health as DATA: `not_run` before any
    /// stats fetch, `empty` (with a populate hint) on a 0-doc index, `populated`
    /// once docs exist — the SAME oracle that goes RED on empty and GREEN on
    /// populated. This is the headless assertion the empty index cannot pass.
    #[test]
    fn search_index_health_flags_empty_index_ragnarok() {
        // (1) Before any Index.Stats fetch: not_run (nothing asserted yet).
        let mut st = SearchState::default();
        let s = st.state_json();
        assert_eq!(s["index_health"], "not_run", "no stats fetched yet");
        assert!(s["index_populate_hint"].is_null());

        // (2) EMPTY index (0 docs) — the walker-bug symptom. This is RAGNARÖK: it
        // MUST read `empty` with a populate hint, NOT a silent pass.
        st.inject_index_stats_for_test(0, vec![("code".into(), "0".into())]);
        let s = st.state_json();
        assert_eq!(
            s["index_health"], "empty",
            "a 0-doc code-index is RAGNARÖK — flagged empty, not passed as green"
        );
        let hint = s["index_populate_hint"].as_str().unwrap_or_default();
        assert!(
            hint.contains("populate") && hint.contains("follow_links"),
            "the empty-index hint names the populate action AND the symlink/walker cause: {hint:?}"
        );
        // The stats block still carries the raw total for the matrix.
        assert_eq!(s["results"]["index_stats"]["total"], 0);

        // (3) POPULATED index — the SAME oracle goes green once docs exist.
        st.inject_index_stats_for_test(
            4210,
            vec![
                ("code".into(), "3900".into()),
                ("docs".into(), "310".into()),
            ],
        );
        let s = st.state_json();
        assert_eq!(
            s["index_health"], "populated",
            "a non-empty index reads populated"
        );
        assert!(
            s["index_populate_hint"].is_null(),
            "no populate hint when populated"
        );
        assert_eq!(s["results"]["index_stats"]["total"], 4210);
    }
}

fn short(s: &str) -> String {
    // Truncate on a char boundary, not a raw byte index: snapshot ids / tags can
    // contain multibyte UTF-8, and `&s[..12]` would panic mid-codepoint.
    if s.chars().count() > 12 {
        let head: String = s.chars().take(12).collect();
        format!("{head}…")
    } else {
        s.to_string()
    }
}

#[cfg(test)]
mod short_tests {
    use super::short;

    #[test]
    fn short_passes_through_when_within_limit() {
        assert_eq!(short("abc123"), "abc123");
        assert_eq!(short("0123456789ab"), "0123456789ab"); // exactly 12 chars
    }

    #[test]
    fn short_truncates_ascii() {
        assert_eq!(short("0123456789abcdef"), "0123456789ab…");
    }

    #[test]
    fn short_never_panics_on_multibyte_boundary() {
        // Multibyte chars straddling byte index 12: a raw `&s[..12]` would panic
        // ("byte index 12 is not a char boundary"). This must truncate cleanly.
        let s = "äöüäöüäöüäöüäöü"; // each char is 2 bytes
        let out = short(s);
        assert_eq!(
            out.chars().take(12).collect::<String>(),
            "äöüäöüäöüäöü…".chars().take(12).collect::<String>()
        );
        // 12 chars kept + ellipsis.
        assert_eq!(out.chars().count(), 13);

        // Emoji (4-byte) snapshot label.
        let s = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
        let out = short(s);
        assert_eq!(out.chars().count(), 13); // 12 crabs + ellipsis
    }
}