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
}
}