tawasuyu

tawasuyu/brahman

Rust 328TOML 124JSON 17Markdown 144636 Definitions

⎇ main crates/apps/minga-explorer/src/main.rs Rust · 341 lines · 11.1 KB History Who touched what raw

refactor(naming): A1 — ente→arje, vista→revista, pluma→fana b83d40a83 · sergio ·

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}