diff --git a/CHANGELOG.md b/CHANGELOG.md index 1343cfe..2636348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ ratio/diff ver `git show `. ## 2026-05-10 +### feat(minga-explorer): nueva app dashboard del repo Minga sobre stack yahweh +Iter 11. Cierra el último frente identificado: integración del +módulo Minga (VCS semántico P2P) al ecosistema GUI. Antes Minga +sólo tenía CLI (`minga init/status/ingest/listen/sync/watch`). +Ahora hay un **dashboard GPUI** que muestra los counts del repo +en vivo, sobre el mismo stack themed que las otras dos apps +explorer. + +Crate nuevo `crates/apps/minga-explorer/`: +- **Deps**: `minga-store` (para `PersistentRepo::open`) + + `yahweh-theme` + `yahweh-widget-{banner,card,theme-switcher}`. + Sin `minga-cli` (no necesita prompts de passphrase) ni + `minga-core` (counts no requieren parsear AST). +- **Lectura sin passphrase**: el `PersistentRepo` se abre directo + desde `/repo` sled. Los counts (`nodes.len()`, + `attestations.len()`, `mst.len()`) son lectura pública. Para el + DID se sigue necesitando `minga status` (CLI con passphrase). +- **Refresh por polling cada 2s**: mismo pattern que + `nakui-explorer`/`nouser-explorer`. +- **3 stat cards** una por dimensión: + - Nodos AST (cyan) — fragments parseados del código. + - Atestaciones (verde) — firmas Ed25519 sobre los nodos. + - Claves MST (purple) — entradas del Merkle Search Tree. +- **Helper `stat_card(cx, label, value, description, accent, ...)`**: + fabrica una card con border-l colored + label tenue + número + grande (`px(28)`) + descripción. Reutilizable. +- **Header**: título dinámico (`Repo: · reload ms`) + + theme switcher derecha. +- **Error banner**: themed Banner::Error si el repo no abre. +- 2 tests: `load_snapshot_errors_on_missing_dir` (msg claro + cuando el dir no existe) + sanity del `RepoSnapshot::default`. + +Workspace: nueva entry en `members[]`. + +Smoke run del binario verificado: bootstrap completo OK, panic +esperado en open_window por falta de display. + +Beneficio operativo: +- Un usuario corre `minga init` + `minga ingest archivo.rs` desde + CLI, después abre `minga-explorer` y ve los counts crecer en + vivo cuando ingiere más archivos. +- Comparte theme switcher con `nakui-explorer` y + `nouser-explorer` — cualquier preset elegido se aplica + visualmente igual cross-app. +- `minga` deja de ser sólo CLI; gana presencia GUI sin tocar + el resto del módulo. + +Apps GUI integradas al stack themed: **4** (nakui-ui, nakui-explorer, +nouser-explorer, minga-explorer). + ### feat(nouser-explorer): integración al stack yahweh themed Iter 10. `nouser-explorer` (la app paralela a `nakui-explorer` para ver Mónadas via daemon nouser) tenía colors hardcoded diff --git a/Cargo.lock b/Cargo.lock index a1f1b43..e790fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6196,6 +6196,18 @@ dependencies = [ "tree-sitter-typescript", ] +[[package]] +name = "minga-explorer" +version = "0.1.0" +dependencies = [ + "gpui", + "minga-store", + "yahweh-theme", + "yahweh-widget-banner", + "yahweh-widget-card", + "yahweh-widget-theme-switcher", +] + [[package]] name = "minga-p2p" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c716a5..ba5fc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ members = [ "crates/apps/nouser-explorer", "crates/apps/nakui-explorer", "crates/apps/nakui-ui", + "crates/apps/minga-explorer", ] [workspace.package] diff --git a/crates/apps/minga-explorer/Cargo.toml b/crates/apps/minga-explorer/Cargo.toml new file mode 100644 index 0000000..995d67c --- /dev/null +++ b/crates/apps/minga-explorer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "minga-explorer" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Dashboard GPUI del repo Minga: counts de nodos AST, atestaciones, claves MST, refresh por polling. Lee sled directo (sin passphrase). Standalone." + +[dependencies] +minga-store = { path = "../../modules/semantic_dht/minga-store" } +yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } +yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } +yahweh-widget-card = { path = "../../modules/ui_engine/widgets/card" } +yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } +gpui = { workspace = true } + +[[bin]] +name = "minga-explorer" +path = "src/main.rs" diff --git a/crates/apps/minga-explorer/src/main.rs b/crates/apps/minga-explorer/src/main.rs new file mode 100644 index 0000000..70b09f9 --- /dev/null +++ b/crates/apps/minga-explorer/src/main.rs @@ -0,0 +1,303 @@ +//! `minga-explorer` — dashboard GPUI del repo Minga (VCS semántico +//! P2P). +//! +//! Polling cada 2s contra `MINGA_REPO` (env, default `./.minga`), +//! abre el `PersistentRepo` (sled, sin passphrase porque los counts +//! son lectura pública) y muestra: +//! - Cantidad de nodos AST almacenados. +//! - Cantidad de atestaciones firmadas. +//! - Cantidad de claves del MST (Merkle Search Tree). +//! +//! No requiere keypair descifrado — eso se queda para el CLI +//! (`minga status`) cuando hace falta el DID. El explorer foco es +//! observabilidad rápida. +//! +//! Stack visual: yahweh-theme + banner_themed + card_themed + +//! theme_switcher. Mismo patrón que `nakui-explorer` / +//! `nouser-explorer`. +//! +//! Uso: +//! ```sh +//! cargo run -p minga-explorer +//! # con repo custom: +//! MINGA_REPO=/path/to/.minga cargo run -p minga-explorer +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use gpui::{ + div, prelude::*, px, App, Application, Bounds, Context, IntoElement, Render, SharedString, + Window, WindowBounds, WindowOptions, +}; +use minga_store::PersistentRepo; +use yahweh_theme::Theme; +use yahweh_widget_banner::{banner_themed, Banner}; +use yahweh_widget_card::card_themed; +use yahweh_widget_theme_switcher::theme_switcher; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); +const REPO_DIRNAME: &str = "repo"; + +fn main() { + Application::new().run(|cx: &mut App| { + Theme::install_default(cx); + let bounds = Bounds::centered(None, gpui::size(px(800.), px(560.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("Minga — Repo")), + ..Default::default() + }), + ..Default::default() + }, + |_w, cx| cx.new(Explorer::new), + ) + .expect("open window"); + cx.activate(true); + }); +} + +/// Snapshot de counts del repo. Se reemplaza completo en cada +/// refresh — los stores no diff fácilmente, y los counts son +/// baratos (sled tracks size). +#[derive(Clone, Default, Debug)] +struct RepoSnapshot { + nodes: usize, + attestations: usize, + mst_keys: usize, +} + +struct Explorer { + repo_path: PathBuf, + snapshot: Option, + error: Option, + last_load_ms: u64, +} + +impl Explorer { + fn new(cx: &mut Context) -> Self { + let repo_path = std::env::var("MINGA_REPO") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".minga")); + let path_for_loop = repo_path.clone(); + cx.spawn(async move |this, cx| { + let timer = cx.background_executor().clone(); + loop { + let started = std::time::Instant::now(); + let result = load_snapshot(&path_for_loop); + let elapsed = started.elapsed().as_millis() as u64; + let _ = this.update(cx, |me, cx| { + match result { + Ok(snap) => { + me.snapshot = Some(snap); + me.error = None; + } + Err(e) => { + me.error = Some(SharedString::from(format!( + "no pude leer repo {}: {}", + me.repo_path.display(), + e + ))); + } + } + me.last_load_ms = elapsed; + cx.notify(); + }); + timer.timer(REFRESH_INTERVAL).await; + } + }) + .detach(); + + Self { + repo_path, + snapshot: None, + error: None, + last_load_ms: 0, + } + } +} + +/// Lee el repo sled `/repo` y devuelve los 3 counts. +/// Falla si: el dir no existe, sled rebota al abrir, o cualquier +/// store falla a `len()`. Ningún error es fatal — la UI muestra el +/// banner y mantiene el último snapshot bueno. +fn load_snapshot(repo_path: &std::path::Path) -> Result { + let inner = repo_path.join(REPO_DIRNAME); + if !inner.exists() { + return Err(format!( + "directorio del repo sled no existe: {}", + inner.display() + )); + } + let repo = PersistentRepo::open(&inner).map_err(|e| format!("open: {e}"))?; + Ok(RepoSnapshot { + nodes: repo.nodes.len(), + attestations: repo.attestations.len(), + mst_keys: repo.mst.len(), + }) +} + +impl Render for Explorer { + fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let bg = theme.bg_app.clone(); + let text = theme.fg_text; + let text_dim = theme.fg_muted; + // Acentos por kind del dashboard: nodos azul, atestaciones + // verde, MST purple. Señales semánticas del dominio Minga. + let accent_nodes = gpui::rgb(0x88c0d0); + let accent_attestations = gpui::rgb(0xa3be8c); + let accent_mst = gpui::rgb(0xb48ead); + + let header_text = match &self.snapshot { + Some(_) => format!( + "Repo: {} · reload {} ms", + self.repo_path.display(), + self.last_load_ms + ), + None => format!("Buscando repo en {}…", self.repo_path.display()), + }; + + let header = div() + .flex() + .flex_row() + .items_center() + .px(px(16.)) + .py(px(12.)) + .bg(theme.bg_panel.clone()) + .border_b_1() + .border_color(theme.border) + .text_color(text) + .text_size(px(14.)) + .child(div().flex_grow().child(header_text)) + .child(theme_switcher(cx)); + + let error_banner = self.error.as_ref().map(|e| { + banner_themed(cx, Banner::Error, e.clone()) + .px(px(16.)) + .py(px(8.)) + .text_size(px(12.)) + }); + + let body = match &self.snapshot { + None => div() + .px(px(16.)) + .py(px(20.)) + .text_color(text_dim) + .text_size(px(13.)) + .child("Esperando primer refresh…"), + Some(snap) => div() + .flex() + .flex_col() + .gap(px(8.)) + .px(px(16.)) + .py(px(16.)) + .child(stat_card( + cx, + "Nodos AST", + snap.nodes, + "fragments parseados del código", + accent_nodes, + text, + text_dim, + )) + .child(stat_card( + cx, + "Atestaciones", + snap.attestations, + "firmas Ed25519 sobre los nodos", + accent_attestations, + text, + text_dim, + )) + .child(stat_card( + cx, + "Claves MST", + snap.mst_keys, + "entradas del Merkle Search Tree", + accent_mst, + text, + text_dim, + )), + }; + + div() + .flex() + .flex_col() + .size_full() + .bg(bg) + .child(header) + .when_some(error_banner, |d, b| d.child(b)) + .child(body) + } +} + +/// Card visual para una estadística del dashboard. Border-l por +/// kind, label arriba + número grande + descripción abajo. +fn stat_card( + cx: &mut Context, + label: &str, + value: usize, + description: &str, + accent: gpui::Rgba, + text: gpui::Hsla, + text_dim: gpui::Hsla, +) -> impl IntoElement { + card_themed(cx) + .border_l_4() + .border_color(accent) + .child( + div() + .text_color(accent) + .text_size(px(11.)) + .child(SharedString::from(label.to_string())), + ) + .child( + div() + .text_color(text) + .text_size(px(28.)) + .child(SharedString::from(value.to_string())), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(SharedString::from(description.to_string())), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sanity: load_snapshot rebota si el dir no existe (mensaje + /// claro). Es el path típico para "no inicializaste el repo". + #[test] + fn load_snapshot_errors_on_missing_dir() { + let p = std::env::temp_dir().join(format!( + "minga-explorer-missing-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + // p NO existe. + let err = load_snapshot(&p).unwrap_err(); + assert!( + err.contains("no existe"), + "msg debe explicar el missing: {err}" + ); + } + + #[test] + fn snapshot_default_is_zeros() { + let s = RepoSnapshot::default(); + assert_eq!(s.nodes, 0); + assert_eq!(s.attestations, 0); + assert_eq!(s.mst_keys, 0); + } +}