⎇ be3a0e78fc894587313cb5e23631daf89ce52976
crates/apps/minga-explorer/src/main.rs
History
Who touched what
raw
feat(yahweh-widget-app-header): promover header standard de explorers
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_stat_card::stat_card;
37use yahweh_widget_app_header::app_header;
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 // Header standard via widget compartido.
228 let header = app_header(cx, header_text);
229
230 let error_banner = self.error.as_ref().map(|e| {
231 banner_themed(cx, Banner::Error, e.clone())
232 .px(px(16.))
233 .py(px(8.))
234 .text_size(px(12.))
235 });
236
237 let body = match &self.snapshot {
238 None => div()
239 .px(px(16.))
240 .py(px(20.))
241 .text_color(text_dim)
242 .text_size(px(13.))
243 .child("Esperando primer refresh…"),
244 Some(snap) => {
245 let node_items: Vec<String> = snap
246 .recent_nodes
247 .iter()
248 .map(|(h, k)| format!("{h} {k}"))
249 .collect();
250 let attestation_items: Vec<String> = snap
251 .recent_attestations
252 .iter()
253 .map(|(h, did)| format!("{h} ← {did}"))
254 .collect();
255 let mst_items: Vec<String> = snap.recent_mst_keys.clone();
256
257 div()
258 .flex()
259 .flex_col()
260 .gap(px(8.))
261 .px(px(16.))
262 .py(px(16.))
263 .child(stat_card(
264 cx,
265 "Nodos AST",
266 snap.nodes.to_string(),
267 "fragments parseados del código",
268 accent_nodes,
269 text,
270 text_dim,
271 &node_items,
272 ))
273 .child(stat_card(
274 cx,
275 "Atestaciones",
276 snap.attestations.to_string(),
277 "firmas Ed25519 sobre los nodos",
278 accent_attestations,
279 text,
280 text_dim,
281 &attestation_items,
282 ))
283 .child(stat_card(
284 cx,
285 "Claves MST",
286 snap.mst_keys.to_string(),
287 "entradas del Merkle Search Tree",
288 accent_mst,
289 text,
290 text_dim,
291 &mst_items,
292 ))
293 }
294 };
295
296 div()
297 .flex()
298 .flex_col()
299 .size_full()
300 .bg(bg)
301 .child(header)
302 .when_some(error_banner, |d, b| d.child(b))
303 .child(body)
304 }
305}
306
307// `stat_card` se promovió a `yahweh-widget-stat-card` y se importa
308// arriba. La fn local fue eliminada en la iter 15 del refactor.
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 /// Sanity: load_snapshot rebota si el dir no existe (mensaje
315 /// claro). Es el path típico para "no inicializaste el repo".
316 #[test]
317 fn load_snapshot_errors_on_missing_dir() {
318 let p = std::env::temp_dir().join(format!(
319 "minga-explorer-missing-{}-{}",
320 std::process::id(),
321 std::time::SystemTime::now()
322 .duration_since(std::time::UNIX_EPOCH)
323 .map(|d| d.as_nanos())
324 .unwrap_or(0)
325 ));
326 // p NO existe.
327 let err = load_snapshot(&p).unwrap_err();
328 assert!(
329 err.contains("no existe"),
330 "msg debe explicar el missing: {err}"
331 );
332 }
333
334 #[test]
335 fn snapshot_default_is_zeros_and_empty_lists() {
336 let s = RepoSnapshot::default();
337 assert_eq!(s.nodes, 0);
338 assert_eq!(s.attestations, 0);
339 assert_eq!(s.mst_keys, 0);
340 assert!(s.recent_nodes.is_empty());
341 assert!(s.recent_attestations.is_empty());
342 assert!(s.recent_mst_keys.is_empty());
343 }
344
345 #[test]
346 fn short_hash_takes_first_12_chars() {
347 let s = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd";
348 assert_eq!(short_hash(s), "a1b2c3d4e5f6");
349 assert_eq!(short_hash(s).len(), 12);
350 }
351
352 #[test]
353 fn short_hash_handles_empty_or_shorter() {
354 assert_eq!(short_hash(""), "");
355 assert_eq!(short_hash("abc"), "abc");
356 }
357}