use eframe::egui::{self};
use super::action_log::{ActionLog, Kind};
use super::facett_theme::{Theme, GREEN, RED};
use super::remote;
#[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."
));
}
#[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>>,
calls: Option<Result<(String, Vec<String>), String>>,
theme: Theme,
}
impl SearchState {
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, 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);
}
});
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));
self.hits = Some(
remote::search(&srv.endpoint, &srv.token, &self.query, &self.corpus, "", 25, workspace)
.map_err(|e| format!("{e:#}")),
);
}
});
});
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);
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);
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);
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:#}"))
}
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()));
serde_json::json!({
"palette": self.theme.name,
"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,
},
"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 }),
},
},
})
}
}
#[derive(Default)]
pub struct GatesState {
repo: String,
report: Option<Result<remote::GateReport, String>>,
trace: Option<Result<String, String>>,
theme: Theme,
}
impl GatesState {
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);
}
});
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); }
}
});
}
}
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 }),
},
})
}
}
pub struct BenchState {
repo: String,
points: Option<Result<Vec<remote::BenchPoint>, String>>,
metric: String,
live: super::bench_live::BenchLive,
theme: Theme,
run_armed: bool,
run_result: Option<Result<remote::OpRunResult, String>>,
}
impl Default for BenchState {
fn default() -> Self {
Self {
repo: String::new(),
points: None,
metric: String::new(),
live: super::bench_live::BenchLive::local(std::path::PathBuf::new()),
theme: Theme::default(),
run_armed: false,
run_result: None,
}
}
}
impl BenchState {
pub fn local(root: std::path::PathBuf) -> Self {
Self {
repo: String::new(),
points: None,
metric: String::new(),
live: super::bench_live::BenchLive::local(root),
theme: Theme::default(),
run_armed: false,
run_result: None,
}
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self {
repo: String::new(),
points: None,
metric: String::new(),
live: super::bench_live::BenchLive::remote(endpoint, token, workspace),
theme: Theme::default(),
run_armed: false,
run_result: None,
}
}
pub fn reload(&mut self) {
self.live.reload();
}
pub fn set_workspace(&mut self, ws: String) {
self.live.set_workspace(ws);
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
self.live.set_palette(t);
}
#[doc(hidden)]
pub fn inject_live_for_test(
&mut self,
telemetry: Vec<crate::warehouse::iceberg::BenchTelemetryRow>,
runs: Vec<(String, crate::bench::BenchRun)>,
) {
self.live.inject_for_test(telemetry, runs);
}
pub fn draw(&mut self, ui: &mut egui::Ui, srv: Option<&Server>, workspace: &str, repos: &[String], log: &ActionLog) {
self.live.draw(ui);
let theme = self.theme;
let Some(srv) = srv else { return local_hint(ui, "Bench") };
ui.heading("π Bench history");
if self.repo.is_empty() {
self.repo = repos.first().cloned().unwrap_or_default();
}
ui.horizontal(|ui| {
ui.label("repo:");
egui::ComboBox::from_id_salt("bench_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);
}
});
ui.add(egui::TextEdit::singleline(&mut self.repo).desired_width(160.0).hint_text("repo name"));
if ui.button("Load history").clicked() && !self.repo.trim().is_empty() {
log.push(Kind::Rpc, format!("Bench.History repo={}", self.repo));
self.points = Some(
remote::bench_history(&srv.endpoint, &srv.token, &self.repo, workspace).map_err(|e| format!("{e:#}")),
);
self.metric.clear();
}
if !self.run_armed {
if ui
.button("βΆ Run bencher")
.on_hover_text("HEAVY / long-running β runs the benches server-side (Ops.RunBench). CLI: nornir bench run <repo>")
.clicked()
{
self.run_armed = true;
}
} else {
ui.colored_label(super::facett_theme::AMBER, "β heavy / long-running β confirm?");
if ui.button("β run").clicked() && !self.repo.trim().is_empty() {
log.push(Kind::Rpc, format!("Ops.RunBench repo={}", self.repo));
self.run_result = Some(
remote::run_bench(&srv.endpoint, &srv.token, self.repo.trim(), workspace)
.map_err(|e| format!("{e:#}")),
);
self.run_armed = false;
}
if ui.button("β cancel").clicked() {
self.run_armed = false;
}
}
});
if let Some(r) = &self.run_result {
match r {
Ok(r) => { ui.colored_label(if r.ok { GREEN } else { RED }, &r.summary); }
Err(e) => { ui.colored_label(RED, e); }
}
}
ui.separator();
let points = match &self.points {
Some(Ok(p)) => p,
Some(Err(e)) => { ui.colored_label(RED, e); return; }
None => { ui.label("pick a repo and load its bench history"); return; }
};
if points.is_empty() {
ui.label("no bench history recorded for this repo");
return;
}
let metrics: Vec<String> = {
let mut m: Vec<String> = points.iter().map(|p| p.metric.clone()).collect();
m.sort();
m.dedup();
m
};
if self.metric.is_empty() {
self.metric = metrics.first().cloned().unwrap_or_default();
}
ui.horizontal(|ui| {
ui.label("metric:");
egui::ComboBox::from_id_salt("bench_metric")
.selected_text(&self.metric)
.show_ui(ui, |ui| {
for m in &metrics {
ui.selectable_value(&mut self.metric, m.clone(), m);
}
});
});
let series: Vec<&remote::BenchPoint> = points.iter().filter(|p| p.metric == self.metric).collect();
if series.is_empty() {
ui.label("no points for this metric");
return;
}
let max = series.iter().map(|p| p.value.abs()).fold(f64::EPSILON, f64::max);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
for p in &series {
ui.horizontal(|ui| {
ui.add_sized([150.0, 16.0], egui::Label::new(
egui::RichText::new(format!("{} {}", p.date, p.version)).monospace().size(11.0),
));
let frac = (p.value.abs() / max) as f32;
let (rect, _) = ui.allocate_exact_size(egui::Vec2::new(ui.available_width() - 90.0, 14.0), egui::Sense::hover());
ui.painter().rect_filled(
egui::Rect::from_min_size(rect.min, egui::Vec2::new((rect.width() * frac).max(1.0), 12.0)),
2.0,
theme.accent,
);
ui.label(egui::RichText::new(format!("{}", p.value)).monospace().size(11.0));
});
}
});
}
pub fn state_json(&self) -> serde_json::Value {
let live = self.live.state_json();
let ops = serde_json::json!({
"buttons": [ { "id": "run_bencher", "rpc": "Ops.RunBench", "heavy": true, "confirm": true } ],
"armed": self.run_armed,
});
let run_result = match &self.run_result {
None => serde_json::json!({ "ran": false }),
Some(Ok(r)) => serde_json::json!({
"ran": true, "ok": r.ok, "summary": r.summary, "run_id": r.run_id,
"targets": r.targets.iter().map(|(n, s, m)| serde_json::json!({ "name": n, "status": s, "message": m })).collect::<Vec<_>>(),
}),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
};
let mut base = match &self.points {
None => serde_json::json!({
"palette": self.theme.name,
"form": { "repo": self.repo, "metric": self.metric },
"loaded": false,
"live": live,
}),
Some(Err(e)) => serde_json::json!({
"palette": self.theme.name,
"form": { "repo": self.repo, "metric": self.metric },
"loaded": true, "ok": false, "error": e,
"live": live,
}),
Some(Ok(points)) => {
let mut metrics: Vec<String> = points.iter().map(|p| p.metric.clone()).collect();
metrics.sort();
metrics.dedup();
let series: Vec<serde_json::Value> = points
.iter()
.filter(|p| p.metric == self.metric)
.map(|p| serde_json::json!({
"date": p.date, "version": p.version, "value": p.value,
}))
.collect();
serde_json::json!({
"palette": self.theme.name,
"form": { "repo": self.repo, "metric": self.metric },
"loaded": true, "ok": true,
"metrics": metrics,
"point_count": points.len(),
"series": series,
"live": live,
})
}
};
base["ops"] = ops;
base["run_result"] = run_result;
base
}
}
#[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 {
#[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()
{
self.sync_result = Some(
remote::fetch_workspace(&srv.endpoint, &srv.token, workspace, true)
.map_err(|e| format!("{e:#}")),
);
self.refresh(srv, workspace); 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();
}
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)| {
(name.clone(), serde_json::json!({ "dirty": dirty, "digest": digest }))
})
.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 }),
}
}
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 {
ui.label(egui::RichText::new(format!(" {name}: {summary}")).size(11.0));
}
}
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); }
}
}
}
}
fn short(s: &str) -> String {
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"); }
#[test]
fn short_truncates_ascii() {
assert_eq!(short("0123456789abcdef"), "0123456789abβ¦");
}
#[test]
fn short_never_panics_on_multibyte_boundary() {
let s = "ÀâüÀâüÀâüÀâüÀâü"; let out = short(s);
assert_eq!(out.chars().take(12).collect::<String>(), "ÀâüÀâüÀâüÀâüβ¦".chars().take(12).collect::<String>());
assert_eq!(out.chars().count(), 13);
let s = "π¦π¦π¦π¦π¦π¦π¦π¦π¦π¦π¦π¦π¦π¦";
let out = short(s);
assert_eq!(out.chars().count(), 13); }
}