//! `nakui-explorer` — panel GPUI que renderea el event log de un //! repo Nakui: timeline de seeds + morphisms con sus parámetros y //! breakdown por entity type. //! //! ## Diseño //! //! Standalone, lee un archivo `.jsonl` (formato append-only del //! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s //! para detectar nuevos eventos appended (típico de un nakui ERP en //! producción que va escribiendo). Sin discovery dinámico vía broker //! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando //! se daemonice, sustituir el lector de archivo por un sidecar //! consumer (mismo patrón que `chasqui-explorer`). //! //! ## Uso //! //! ```sh //! # Path explícito: //! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer //! //! # Default si la env no está: ./nakui.jsonl en pwd. //! cargo run -p nakui-explorer //! ``` use std::path::PathBuf; use std::time::Duration; use gpui::{ div, prelude::*, px, rgb, Context, IntoElement, Render, SharedString, Window, }; use nakui_core::event_log::{EventLog, LogEntry}; use nahual_launcher::launch_app; use nahual_meta_runtime::{preview_value, short_hash, short_uuid}; use nahual_theme::Theme; use nahual_widget_app_header::app_header; use nahual_widget_banner::{banner_themed, Banner}; use nahual_widget_card::card_themed; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); fn main() { launch_app("Nakui — Event Log", (900., 640.), Explorer::new); } /// Estado de la vista. `entries` se reescribe en cada tick (el log /// es append-only así que reload completo es seguro y barato hasta /// decenas de miles de entries; optimizar a delta cuando duela). struct Explorer { log_path: PathBuf, entries: Vec, error: Option, last_load_ms: u64, } impl Explorer { fn new(cx: &mut Context) -> Self { let log_path = std::env::var("NAKUI_EVENT_LOG") .ok() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("nakui.jsonl")); // Loop de refresh. let path_for_loop = log_path.clone(); cx.spawn(async move |this, cx| { let timer = cx.background_executor().clone(); loop { let started = std::time::Instant::now(); let result = load_log(&path_for_loop); let elapsed_ms = started.elapsed().as_millis() as u64; let _ = this.update(cx, |me, cx| { match result { Ok(entries) => { me.entries = entries; me.error = None; } Err(e) => { me.error = Some(SharedString::from(format!( "no pude leer {}: {}", me.log_path.display(), e ))); } } me.last_load_ms = elapsed_ms; cx.notify(); }); timer.timer(REFRESH_INTERVAL).await; } }) .detach(); Self { log_path, entries: Vec::new(), error: None, last_load_ms: 0, } } fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) { let mut seeds = 0; let mut morphisms = 0; let mut entity_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); for e in &self.entries { match e { LogEntry::Seed { entity, .. } => { seeds += 1; *entity_counts.entry(entity.clone()).or_default() += 1; } LogEntry::Morphism { morphism, .. } => { morphisms += 1; *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1; } } } let mut ranked: Vec<_> = entity_counts.into_iter().collect(); ranked.sort_by(|a, b| b.1.cmp(&a.1)); (seeds, morphisms, ranked) } } fn load_log(path: &std::path::Path) -> Result, String> { let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?; log.entries().map_err(|e| format!("read: {e}")) } impl Render for Explorer { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { // Colores cromáticos del chrome del app vienen del Theme // global (instalado en main). Los acentos por kind (seed // azul, morphism verde) siguen siendo locales: son señales // semánticas del log, no del chrome. let theme = Theme::global(cx).clone(); let bg = theme.bg_app.clone(); let text = theme.fg_text; let text_dim = theme.fg_muted; let accent_seed = rgb(0x88c0d0); let accent_morphism = rgb(0xa3be8c); let (seed_count, morphism_count, top_breakdown) = self.breakdown(); let header_text = format!( "Log: {} · {} entries ({} seeds, {} morphisms) · reload {} ms", self.log_path.display(), self.entries.len(), seed_count, morphism_count, self.last_load_ms, ); // Header standard via widget compartido nahual-widget-app-header // (label flex_grow + theme switcher derecha + bg panel + border // bottom + text styling consistente). let header = app_header(cx, header_text); let breakdown_line = if top_breakdown.is_empty() { String::new() } else { let parts: Vec = top_breakdown .iter() .take(5) .map(|(k, v)| format!("{k}({v})")) .collect(); format!("breakdown: {}", parts.join(", ")) }; let breakdown_div = (!breakdown_line.is_empty()).then(|| { div() .px(px(16.)) .py(px(6.)) .bg(theme.bg_panel_alt.clone()) .text_color(text_dim) .text_size(px(11.)) .child(breakdown_line) }); // Banner de error themed: deriva (bg, fg) del Theme actual // según `Banner::Error` + `is_dark`. Padding extra (16/8) // del header preservado via overrides del builder. 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.)) }); // Renderea las últimas N entries (la timeline crece hacia abajo // en append-order; mostramos las más recientes primero para // que el usuario vea actividad reciente sin scroll). const MAX_VISIBLE: usize = 200; let visible: Vec<_> = self .entries .iter() .rev() .take(MAX_VISIBLE) .collect(); let cards: Vec = visible .iter() .map(|e| match e { LogEntry::Seed { seq, entity, id, data, schema_hash, } => { let data_preview = preview_value(data, 80); let schema_label = schema_hash .as_ref() .map(|h| format!("schema={}", short_hash(h))) .unwrap_or_else(|| "schema=(legacy)".into()); card_themed(cx) .border_l_4() .border_color(accent_seed) .child( div() .flex() .flex_row() .gap(px(8.)) .items_center() .child( div() .text_color(accent_seed) .text_size(px(11.)) .child(format!("[#{seq} seed]")), ) .child( div() .text_color(text) .text_size(px(13.)) .child(entity.clone()), ) .child( div() .text_color(text_dim) .text_size(px(10.)) .child(format!("id={}", short_uuid(id))), ), ) .child( div() .text_color(text_dim) .text_size(px(11.)) .child(data_preview), ) .child( div() .text_color(text_dim) .text_size(px(10.)) .child(schema_label), ) .into_any_element() } LogEntry::Morphism { seq, morphism, inputs, params, ops, schema_hash, } => { let inputs_line = if inputs.is_empty() { String::new() } else { let parts: Vec = inputs .iter() .map(|(name, id)| format!("{name}={}", short_uuid(id))) .collect(); format!("inputs: {}", parts.join(", ")) }; let params_line = preview_value(params, 80); let ops_line = format!("{} op(s)", ops.len()); let schema_label = schema_hash .as_ref() .map(|h| format!("schema={}", short_hash(h))) .unwrap_or_else(|| "schema=(legacy)".into()); card_themed(cx) .border_l_4() .border_color(accent_morphism) .child( div() .flex() .flex_row() .gap(px(8.)) .items_center() .child( div() .text_color(accent_morphism) .text_size(px(11.)) .child(format!("[#{seq} morph]")), ) .child( div() .text_color(text) .text_size(px(13.)) .child(morphism.clone()), ) .child( div() .text_color(text_dim) .text_size(px(10.)) .child(ops_line), ), ) .when(!inputs_line.is_empty(), |d| { d.child( div() .text_color(text_dim) .text_size(px(11.)) .child(inputs_line), ) }) .when(!params_line.is_empty(), |d| { d.child( div() .text_color(text_dim) .text_size(px(11.)) .child(format!("params: {params_line}")), ) }) .child( div() .text_color(text_dim) .text_size(px(10.)) .child(schema_label), ) .into_any_element() } }) .collect(); let body = div() .flex() .flex_col() .p(px(12.)) .overflow_hidden() .children(cards); div() .flex() .flex_col() .size_full() .bg(bg) .child(header) .when_some(breakdown_div, |d, b| d.child(b)) .when_some(error_banner, |d, b| d.child(b)) .child(body) } } // Helpers `short_uuid`, `short_hash`, `preview_value` viven en // `nahual_meta_runtime::format`. Se usan acá via el `use` de arriba. #[cfg(test)] mod tests { use super::*; use std::io::Write; fn write_sample_log() -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); // 3 seeds + 2 morphisms, formato canónico de event_log. let lines = [ r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#, r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#, r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#, r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#, r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#, ]; for l in lines { writeln!(f, "{l}").unwrap(); } f.flush().unwrap(); f } #[test] fn load_log_returns_all_entries_in_order() { let f = write_sample_log(); let entries = load_log(f.path()).expect("load"); assert_eq!(entries.len(), 5); for (i, e) in entries.iter().enumerate() { assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous"); } } #[test] fn breakdown_counts_seeds_morphisms_and_buckets() { let f = write_sample_log(); let entries = load_log(f.path()).unwrap(); let me = Explorer { log_path: f.path().to_path_buf(), entries, error: None, last_load_ms: 0, }; let (seeds, morphisms, ranked) = me.breakdown(); assert_eq!(seeds, 3); assert_eq!(morphisms, 2); // Buckets esperados: product (2), customer (1), → sale.create (1), // → sale.refund (1). assert_eq!(ranked.len(), 4); let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect(); assert_eq!(map.get("product"), Some(&2)); assert_eq!(map.get("customer"), Some(&1)); assert_eq!(map.get("→ sale.create"), Some(&1)); assert_eq!(map.get("→ sale.refund"), Some(&1)); } #[test] fn load_missing_file_yields_empty_not_error() { // EventLog::open de un archivo inexistente no falla; entries() devuelve []. let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl"); let _ = std::fs::remove_file(&path); let result = load_log(&path).expect("missing path is OK per EventLog::open contract"); assert!(result.is_empty()); } // Tests de `short_uuid` / `short_hash` / `preview_value` viven // en `nahual-meta-runtime::format` tras la migración. Si esos se // vuelven a romper, los tests específicos del crate runtime los // capturan; acá no duplicamos. }