refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
1//! `minga-explorer` — dashboard GPUI del repo Minga (VCS semántico
2//! P2P).
3//!
4//! Polling cada 2s contra `MINGA_REPO` (env, default `./.minga`),
5//! abre el `PersistentRepo` (sled, sin passphrase porque los counts
6//! son lectura pública) y muestra:
7//! - Cantidad de nodos AST almacenados.
8//! - Cantidad de atestaciones firmadas.
9//! - Cantidad de claves del MST (Merkle Search Tree).
10//!
11//! No requiere keypair descifrado — eso se queda para el CLI
12//! (`minga status`) cuando hace falta el DID. El explorer foco es
13//! observabilidad rápida.
14//!
15//! Stack visual: nahual-theme + banner_themed + card_themed +
16//! theme_switcher. Mismo patrón que `nakui-explorer` /
17//! `chasqui-explorer`.
18//!
19//! Uso:
20//! ```sh
21//! cargo run -p minga-explorer
22//! # con repo custom:
23//! MINGA_REPO=/path/to/.minga cargo run -p minga-explorer
24//! ```
25
26use std::path::PathBuf;
27use std::time::Duration;
28
29use gpui::{
30 div, prelude::*, px, Context, IntoElement, Render, SharedString, Window,
31};
32use minga_store::PersistentRepo;
33use nahual_launcher::launch_app;
34use nahual_theme::Theme;
35use nahual_widget_app_header::app_header;
36use nahual_widget_banner::{banner_themed, Banner};
37use nahual_widget_stat_card::stat_card;
38
39const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
40const REPO_DIRNAME: &str = "repo";
41
42fn main() {
43 launch_app("Minga — Repo", (800., 560.), Explorer::new);
44}
45
46/// Cuántos items recientes mostrar por sección. Los stores no
47/// tienen orden cronológico (sled ordena lexicográfico por hash);
48/// los "recent" acá son simplemente los primeros del iter — sirve
49/// como sample, no como log temporal. Para timeline real haría
50/// falta agregar timestamp al schema.
51const RECENT_LIMIT: usize = 5;
52
53/// Snapshot de counts + sample de items recientes. Reemplaza el
54/// completo en cada refresh — los stores no diff fácilmente y los
55/// counts son baratos (sled tracks size).
56#[derive(Clone, Default, Debug)]
57struct RepoSnapshot {
58 nodes: usize,
59 attestations: usize,
60 mst_keys: usize,
61 /// Sample de nodos: `(hash_short, kind)`.
62 recent_nodes: Vec<(String, String)>,
63 /// Sample de atestaciones: `(content_hash_short, did_short)`.
64 recent_attestations: Vec<(String, String)>,
65 /// Sample de claves MST: `hash_short`.
66 recent_mst_keys: Vec<String>,
67}
68
69struct Explorer {
70 repo_path: PathBuf,
71 snapshot: Option<RepoSnapshot>,
72 error: Option<SharedString>,
73 last_load_ms: u64,
74}
75
76impl Explorer {
77 fn new(cx: &mut Context<Self>) -> Self {
78 let repo_path = std::env::var("MINGA_REPO")
79 .ok()
80 .map(PathBuf::from)
81 .unwrap_or_else(|| PathBuf::from(".minga"));
82 let path_for_loop = repo_path.clone();
83 cx.spawn(async move |this, cx| {
84 let timer = cx.background_executor().clone();
85 loop {
86 let started = std::time::Instant::now();
87 let result = load_snapshot(&path_for_loop);
88 let elapsed = started.elapsed().as_millis() as u64;
89 let _ = this.update(cx, |me, cx| {
90 match result {
91 Ok(snap) => {
92 me.snapshot = Some(snap);
93 me.error = None;
94 }
95 Err(e) => {
96 me.error = Some(SharedString::from(format!(
97 "no pude leer repo {}: {}",
98 me.repo_path.display(),
99 e
100 )));
101 }
102 }
103 me.last_load_ms = elapsed;
104 cx.notify();
105 });
106 timer.timer(REFRESH_INTERVAL).await;
107 }
108 })
109 .detach();
110
111 Self {
112 repo_path,
113 snapshot: None,
114 error: None,
115 last_load_ms: 0,
116 }
117 }
118}
119
120/// Lee el repo sled `<repo_path>/repo` y devuelve los 3 counts.
121/// Falla si: el dir no existe, sled rebota al abrir, o cualquier
122/// store falla a `len()`. Ningún error es fatal — la UI muestra el
123/// banner y mantiene el último snapshot bueno.
124fn load_snapshot(repo_path: &std::path::Path) -> Result<RepoSnapshot, String> {
125 let inner = repo_path.join(REPO_DIRNAME);
126 if !inner.exists() {
127 return Err(format!(
128 "directorio del repo sled no existe: {}",
129 inner.display()
130 ));
131 }
132 let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?;
133
134 // Counts: cheap (sled tracks size).
135 let nodes = repo.nodes.len();
136 let attestations = repo.attestations.len();
137 let mst_keys = repo.mst.len();
138
139 // Samples: tomar los primeros RECENT_LIMIT items del iter.
140 // Errores per-item se silencian (filter_map) porque el dashboard
141 // muestra lo que pueda; un par de items corruptos no debería
142 // tirar el panel entero.
143 let recent_nodes: Vec<(String, String)> = repo
144 .nodes
145 .iter()
146 .filter_map(|r| r.ok())
147 .take(RECENT_LIMIT)
148 .map(|(hash, stored)| (short_hash(&hash.to_string()), stored.kind))
149 .collect();
150
151 let recent_attestations: Vec<(String, String)> = repo
152 .attestations
153 .iter()
154 .filter_map(|r| r.ok())
155 .take(RECENT_LIMIT)
156 .map(|att| {
157 (
158 short_hash(&att.content.to_string()),
159 short_hash(&att.author.to_string()),
160 )
161 })
162 .collect();
163
164 let recent_mst_keys: Vec<String> = repo
165 .mst
166 .iter()
167 .filter_map(|r| r.ok())
168 .take(RECENT_LIMIT)
169 .map(|h| short_hash(&h.to_string()))
170 .collect();
171
172 Ok(RepoSnapshot {
173 nodes,
174 attestations,
175 mst_keys,
176 recent_nodes,
177 recent_attestations,
178 recent_mst_keys,
179 })
180}
181
182/// Trunca un hex string a sus primeros 12 chars. Convención cross-app
183/// para mostrar hashes/dids/contenthash compactos sin perder
184/// distintividad práctica (12 hex = 48 bits, colisión improbable
185/// dentro de un repo single-machine).
186fn short_hash(s: &str) -> String {
187 s.chars().take(12).collect()
188}
189
190impl Render for Explorer {
191 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
192 let theme = Theme::global(cx).clone();
193 let bg = theme.bg_app.clone();
194 let text = theme.fg_text;
195 let text_dim = theme.fg_muted;
196 // Acentos por kind del dashboard: nodos azul, atestaciones
197 // verde, MST purple. Señales semánticas del dominio Minga.
198 let accent_nodes = gpui::rgb(0x88c0d0);
199 let accent_attestations = gpui::rgb(0xa3be8c);
200 let accent_mst = gpui::rgb(0xb48ead);
201
202 let header_text = match &self.snapshot {
203 Some(_) => format!(
204 "Repo: {} · reload {} ms",
205 self.repo_path.display(),
206 self.last_load_ms
207 ),
208 None => format!("Buscando repo en {}…", self.repo_path.display()),
209 };
210
211 // Header standard via widget compartido.
212 let header = app_header(cx, header_text);
213
214 let error_banner = self.error.as_ref().map(|e| {
215 banner_themed(cx, Banner::Error, e.clone())
216 .px(px(16.))
217 .py(px(8.))
218 .text_size(px(12.))
219 });
220
221 let body = match &self.snapshot {
222 None => div()
223 .px(px(16.))
224 .py(px(20.))
225 .text_color(text_dim)
226 .text_size(px(13.))
227 .child("Esperando primer refresh…"),
228 Some(snap) => {
229 let node_items: Vec<String> = snap
230 .recent_nodes
231 .iter()
232 .map(|(h, k)| format!("{h} {k}"))
233 .collect();
234 let attestation_items: Vec<String> = snap
235 .recent_attestations
236 .iter()
237 .map(|(h, did)| format!("{h} ← {did}"))
238 .collect();
239 let mst_items: Vec<String> = snap.recent_mst_keys.clone();
240
241 div()
242 .flex()
243 .flex_col()
244 .gap(px(8.))
245 .px(px(16.))
246 .py(px(16.))
247 .child(stat_card(
248 cx,
249 "Nodos AST",
250 snap.nodes.to_string(),
251 "fragments parseados del código",
252 accent_nodes,
253 text,
254 text_dim,
255 &node_items,
256 ))
257 .child(stat_card(
258 cx,
259 "Atestaciones",
260 snap.attestations.to_string(),
261 "firmas Ed25519 sobre los nodos",
262 accent_attestations,
263 text,
264 text_dim,
265 &attestation_items,
266 ))
267 .child(stat_card(
268 cx,
269 "Claves MST",
270 snap.mst_keys.to_string(),
271 "entradas del Merkle Search Tree",
272 accent_mst,
273 text,
274 text_dim,
275 &mst_items,
276 ))
277 }
278 };
279
280 div()
281 .flex()
282 .flex_col()
283 .size_full()
284 .bg(bg)
285 .child(header)
286 .when_some(error_banner, |d, b| d.child(b))
287 .child(body)
288 }
289}
290
291// `stat_card` se promovió a `nahual-widget-stat-card` y se importa
292// arriba. La fn local fue eliminada en la iter 15 del refactor.
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 /// Sanity: load_snapshot rebota si el dir no existe (mensaje
299 /// claro). Es el path típico para "no inicializaste el repo".
300 #[test]
301 fn load_snapshot_errors_on_missing_dir() {
302 let p = std::env::temp_dir().join(format!(
303 "minga-explorer-missing-{}-{}",
304 std::process::id(),
305 std::time::SystemTime::now()
306 .duration_since(std::time::UNIX_EPOCH)
307 .map(|d| d.as_nanos())
308 .unwrap_or(0)
309 ));
310 // p NO existe.
311 let err = load_snapshot(&p).unwrap_err();
312 assert!(
313 err.contains("no existe"),
314 "msg debe explicar el missing: {err}"
315 );
316 }
317
318 #[test]
319 fn snapshot_default_is_zeros_and_empty_lists() {
320 let s = RepoSnapshot::default();
321 assert_eq!(s.nodes, 0);
322 assert_eq!(s.attestations, 0);
323 assert_eq!(s.mst_keys, 0);
324 assert!(s.recent_nodes.is_empty());
325 assert!(s.recent_attestations.is_empty());
326 assert!(s.recent_mst_keys.is_empty());
327 }
328
329 #[test]
330 fn short_hash_takes_first_12_chars() {
331 let s = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
332 assert_eq!(short_hash(s), "a1b2c3d4e5f6");
333 assert_eq!(short_hash(s).len(), 12);
334 }
335
336 #[test]
337 fn short_hash_handles_empty_or_shorter() {
338 assert_eq!(short_hash(""), "");
339 assert_eq!(short_hash("abc"), "abc");
340 }
341}