⎇ 99838b849b577dde5cdac7171a3923d6874a74e3
crates/apps/minga-explorer/src/main.rs
Historia
Quién tocó qué
crudo
feat(minga-explorer): listings de items recientes en cada stat card
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: yahweh-theme + banner_themed + card_themed +
16//! theme_switcher. Mismo patrón que `nakui-explorer` /
17//! `nouser-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, App, Application, Bounds, Context, IntoElement, Render, SharedString,
31 Window, WindowBounds, WindowOptions,
32};
33use minga_store::PersistentRepo;
34use yahweh_theme::Theme;
35use yahweh_widget_banner::{banner_themed, Banner};
36use yahweh_widget_card::card_themed;
37use yahweh_widget_theme_switcher::theme_switcher;
38
39const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
40const REPO_DIRNAME: &str = "repo";
41
42fn main() {
43 Application::new().run(|cx: &mut App| {
44 Theme::install_default(cx);
45 let bounds = Bounds::centered(None, gpui::size(px(800.), px(560.)), cx);
46 cx.open_window(
47 WindowOptions {
48 window_bounds: Some(WindowBounds::Windowed(bounds)),
49 titlebar: Some(gpui::TitlebarOptions {
50 title: Some(SharedString::from("Minga — Repo")),
51 ..Default::default()
52 }),
53 ..Default::default()
54 },
55 |_w, cx| cx.new(Explorer::new),
56 )
57 .expect("open window");
58 cx.activate(true);
59 });
60}
61
62/// Cuántos items recientes mostrar por sección. Los stores no
63/// tienen orden cronológico (sled ordena lexicográfico por hash);
64/// los "recent" acá son simplemente los primeros del iter — sirve
65/// como sample, no como log temporal. Para timeline real haría
66/// falta agregar timestamp al schema.
67const RECENT_LIMIT: usize = 5;
68
69/// Snapshot de counts + sample de items recientes. Reemplaza el
70/// completo en cada refresh — los stores no diff fácilmente y los
71/// counts son baratos (sled tracks size).
72#[derive(Clone, Default, Debug)]
73struct RepoSnapshot {
74 nodes: usize,
75 attestations: usize,
76 mst_keys: usize,
77 /// Sample de nodos: `(hash_short, kind)`.
78 recent_nodes: Vec<(String, String)>,
79 /// Sample de atestaciones: `(content_hash_short, did_short)`.
80 recent_attestations: Vec<(String, String)>,
81 /// Sample de claves MST: `hash_short`.
82 recent_mst_keys: Vec<String>,
83}
84
85struct Explorer {
86 repo_path: PathBuf,
87 snapshot: Option<RepoSnapshot>,
88 error: Option<SharedString>,
89 last_load_ms: u64,
90}
91
92impl Explorer {
93 fn new(cx: &mut Context<Self>) -> Self {
94 let repo_path = std::env::var("MINGA_REPO")
95 .ok()
96 .map(PathBuf::from)
97 .unwrap_or_else(|| PathBuf::from(".minga"));
98 let path_for_loop = repo_path.clone();
99 cx.spawn(async move |this, cx| {
100 let timer = cx.background_executor().clone();
101 loop {
102 let started = std::time::Instant::now();
103 let result = load_snapshot(&path_for_loop);
104 let elapsed = started.elapsed().as_millis() as u64;
105 let _ = this.update(cx, |me, cx| {
106 match result {
107 Ok(snap) => {
108 me.snapshot = Some(snap);
109 me.error = None;
110 }
111 Err(e) => {
112 me.error = Some(SharedString::from(format!(
113 "no pude leer repo {}: {}",
114 me.repo_path.display(),
115 e
116 )));
117 }
118 }
119 me.last_load_ms = elapsed;
120 cx.notify();
121 });
122 timer.timer(REFRESH_INTERVAL).await;
123 }
124 })
125 .detach();
126
127 Self {
128 repo_path,
129 snapshot: None,
130 error: None,
131 last_load_ms: 0,
132 }
133 }
134}
135
136/// Lee el repo sled `<repo_path>/repo` y devuelve los 3 counts.
137/// Falla si: el dir no existe, sled rebota al abrir, o cualquier
138/// store falla a `len()`. Ningún error es fatal — la UI muestra el
139/// banner y mantiene el último snapshot bueno.
140fn load_snapshot(repo_path: &std::path::Path) -> Result<RepoSnapshot, String> {
141 let inner = repo_path.join(REPO_DIRNAME);
142 if !inner.exists() {
143 return Err(format!(
144 "directorio del repo sled no existe: {}",
145 inner.display()
146 ));
147 }
148 let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?;
149
150 // Counts: cheap (sled tracks size).
151 let nodes = repo.nodes.len();
152 let attestations = repo.attestations.len();
153 let mst_keys = repo.mst.len();
154
155 // Samples: tomar los primeros RECENT_LIMIT items del iter.
156 // Errores per-item se silencian (filter_map) porque el dashboard
157 // muestra lo que pueda; un par de items corruptos no debería
158 // tirar el panel entero.
159 let recent_nodes: Vec<(String, String)> = repo
160 .nodes
161 .iter()
162 .filter_map(|r| r.ok())
163 .take(RECENT_LIMIT)
164 .map(|(hash, stored)| (short_hash(&hash.to_string()), stored.kind))
165 .collect();
166
167 let recent_attestations: Vec<(String, String)> = repo
168 .attestations
169 .iter()
170 .filter_map(|r| r.ok())
171 .take(RECENT_LIMIT)
172 .map(|att| {
173 (
174 short_hash(&att.content.to_string()),
175 short_hash(&att.author.to_string()),
176 )
177 })
178 .collect();
179
180 let recent_mst_keys: Vec<String> = repo
181 .mst
182 .iter()
183 .filter_map(|r| r.ok())
184 .take(RECENT_LIMIT)
185 .map(|h| short_hash(&h.to_string()))
186 .collect();
187
188 Ok(RepoSnapshot {
189 nodes,
190 attestations,
191 mst_keys,
192 recent_nodes,
193 recent_attestations,
194 recent_mst_keys,
195 })
196}
197
198/// Trunca un hex string a sus primeros 12 chars. Convención cross-app
199/// para mostrar hashes/dids/contenthash compactos sin perder
200/// distintividad práctica (12 hex = 48 bits, colisión improbable
201/// dentro de un repo single-machine).
202fn short_hash(s: &str) -> String {
203 s.chars().take(12).collect()
204}
205
206impl Render for Explorer {
207 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
208 let theme = Theme::global(cx).clone();
209 let bg = theme.bg_app.clone();
210 let text = theme.fg_text;
211 let text_dim = theme.fg_muted;
212 // Acentos por kind del dashboard: nodos azul, atestaciones
213 // verde, MST purple. Señales semánticas del dominio Minga.
214 let accent_nodes = gpui::rgb(0x88c0d0);
215 let accent_attestations = gpui::rgb(0xa3be8c);
216 let accent_mst = gpui::rgb(0xb48ead);
217
218 let header_text = match &self.snapshot {
219 Some(_) => format!(
220 "Repo: {} · reload {} ms",
221 self.repo_path.display(),
222 self.last_load_ms
223 ),
224 None => format!("Buscando repo en {}…", self.repo_path.display()),
225 };
226
227 let header = div()
228 .flex()
229 .flex_row()
230 .items_center()
231 .px(px(16.))
232 .py(px(12.))
233 .bg(theme.bg_panel.clone())
234 .border_b_1()
235 .border_color(theme.border)
236 .text_color(text)
237 .text_size(px(14.))
238 .child(div().flex_grow().child(header_text))
239 .child(theme_switcher(cx));
240
241 let error_banner = self.error.as_ref().map(|e| {
242 banner_themed(cx, Banner::Error, e.clone())
243 .px(px(16.))
244 .py(px(8.))
245 .text_size(px(12.))
246 });
247
248 let body = match &self.snapshot {
249 None => div()
250 .px(px(16.))
251 .py(px(20.))
252 .text_color(text_dim)
253 .text_size(px(13.))
254 .child("Esperando primer refresh…"),
255 Some(snap) => {
256 let node_items: Vec<String> = snap
257 .recent_nodes
258 .iter()
259 .map(|(h, k)| format!("{h} {k}"))
260 .collect();
261 let attestation_items: Vec<String> = snap
262 .recent_attestations
263 .iter()
264 .map(|(h, did)| format!("{h} ← {did}"))
265 .collect();
266 let mst_items: Vec<String> = snap.recent_mst_keys.clone();
267
268 div()
269 .flex()
270 .flex_col()
271 .gap(px(8.))
272 .px(px(16.))
273 .py(px(16.))
274 .child(stat_card(
275 cx,
276 "Nodos AST",
277 snap.nodes,
278 "fragments parseados del código",
279 accent_nodes,
280 text,
281 text_dim,
282 &node_items,
283 ))
284 .child(stat_card(
285 cx,
286 "Atestaciones",
287 snap.attestations,
288 "firmas Ed25519 sobre los nodos",
289 accent_attestations,
290 text,
291 text_dim,
292 &attestation_items,
293 ))
294 .child(stat_card(
295 cx,
296 "Claves MST",
297 snap.mst_keys,
298 "entradas del Merkle Search Tree",
299 accent_mst,
300 text,
301 text_dim,
302 &mst_items,
303 ))
304 }
305 };
306
307 div()
308 .flex()
309 .flex_col()
310 .size_full()
311 .bg(bg)
312 .child(header)
313 .when_some(error_banner, |d, b| d.child(b))
314 .child(body)
315 }
316}
317
318/// Card visual para una estadística del dashboard. Border-l por
319/// kind, label arriba + número grande + descripción + listing de
320/// items recientes (puede estar vacío). Items se renderean en
321/// `monospace`-look (text_size chico) — útil para hashes/dids.
322fn stat_card(
323 cx: &mut Context<Explorer>,
324 label: &str,
325 value: usize,
326 description: &str,
327 accent: gpui::Rgba,
328 text: gpui::Hsla,
329 text_dim: gpui::Hsla,
330 recent_items: &[String],
331) -> impl IntoElement {
332 let mut card = card_themed(cx)
333 .border_l_4()
334 .border_color(accent)
335 .child(
336 div()
337 .text_color(accent)
338 .text_size(px(11.))
339 .child(SharedString::from(label.to_string())),
340 )
341 .child(
342 div()
343 .text_color(text)
344 .text_size(px(28.))
345 .child(SharedString::from(value.to_string())),
346 )
347 .child(
348 div()
349 .text_color(text_dim)
350 .text_size(px(11.))
351 .child(SharedString::from(description.to_string())),
352 );
353
354 if !recent_items.is_empty() {
355 // Header de la sub-section.
356 card = card.child(
357 div()
358 .mt(px(6.))
359 .text_color(text_dim)
360 .text_size(px(10.))
361 .child(SharedString::from(format!(
362 "recent ({} de {}):",
363 recent_items.len(),
364 value
365 ))),
366 );
367 // Una linea por item.
368 for it in recent_items {
369 card = card.child(
370 div()
371 .text_color(text)
372 .text_size(px(11.))
373 .child(SharedString::from(it.clone())),
374 );
375 }
376 }
377
378 card
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 /// Sanity: load_snapshot rebota si el dir no existe (mensaje
386 /// claro). Es el path típico para "no inicializaste el repo".
387 #[test]
388 fn load_snapshot_errors_on_missing_dir() {
389 let p = std::env::temp_dir().join(format!(
390 "minga-explorer-missing-{}-{}",
391 std::process::id(),
392 std::time::SystemTime::now()
393 .duration_since(std::time::UNIX_EPOCH)
394 .map(|d| d.as_nanos())
395 .unwrap_or(0)
396 ));
397 // p NO existe.
398 let err = load_snapshot(&p).unwrap_err();
399 assert!(
400 err.contains("no existe"),
401 "msg debe explicar el missing: {err}"
402 );
403 }
404
405 #[test]
406 fn snapshot_default_is_zeros_and_empty_lists() {
407 let s = RepoSnapshot::default();
408 assert_eq!(s.nodes, 0);
409 assert_eq!(s.attestations, 0);
410 assert_eq!(s.mst_keys, 0);
411 assert!(s.recent_nodes.is_empty());
412 assert!(s.recent_attestations.is_empty());
413 assert!(s.recent_mst_keys.is_empty());
414 }
415
416 #[test]
417 fn short_hash_takes_first_12_chars() {
418 let s = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
419 assert_eq!(short_hash(s), "a1b2c3d4e5f6");
420 assert_eq!(short_hash(s).len(), 12);
421 }
422
423 #[test]
424 fn short_hash_handles_empty_or_shorter() {
425 assert_eq!(short_hash(""), "");
426 assert_eq!(short_hash("abc"), "abc");
427 }
428}