tawasuyu

tawasuyu/brahman

Rust 334TOML 129Markdown 39JSON 174745 Definitions

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

feat(yahweh-widget-app-header): promover header standard de explorers be3a0e78f · 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: 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}