tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

⎇ 9632d8c4a7920e3f1cbe57d84dadae5527fa1211 crates/apps/nakui-explorer/src/main.rs Rust · 447 siq'ikuna · 16.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(yahweh): theme integration en banner + card + nakui-explorer consume themed 9632d8c4a · Sergio ·

1//! `nakui-explorer` — panel GPUI que renderea el event log de un
2//! repo Nakui: timeline de seeds + morphisms con sus parámetros y
3//! breakdown por entity type.
4//!
5//! ## Diseño
6//!
7//! Standalone, lee un archivo `.jsonl` (formato append-only del
8//! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s
9//! para detectar nuevos eventos appended (típico de un nakui ERP en
10//! producción que va escribiendo). Sin discovery dinámico vía broker
11//! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando
12//! se daemonice, sustituir el lector de archivo por un sidecar
13//! consumer (mismo patrón que `nouser-explorer`).
14//!
15//! ## Uso
16//!
17//! ```sh
18//! # Path explícito:
19//! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer
20//!
21//! # Default si la env no está: ./nakui.jsonl en pwd.
22//! cargo run -p nakui-explorer
23//! ```
24
25use std::path::PathBuf;
26use std::time::Duration;
27
28use gpui::{
29    div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render,
30    SharedString, Window, WindowBounds, WindowOptions,
31};
32use nakui_core::event_log::{EventLog, LogEntry};
33use yahweh_meta_runtime::{preview_value, short_hash, short_uuid};
34use yahweh_theme::Theme;
35use yahweh_widget_banner::{banner_themed, Banner};
36use yahweh_widget_card::card_themed;
37
38const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
39
40fn main() {
41    Application::new().run(|cx: &mut App| {
42        // Theme global instalado al boot — los widgets themed lo
43        // requieren, y simplifica el chrome del app a una paleta
44        // consistente.
45        Theme::install_default(cx);
46        let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
47        cx.open_window(
48            WindowOptions {
49                window_bounds: Some(WindowBounds::Windowed(bounds)),
50                titlebar: Some(gpui::TitlebarOptions {
51                    title: Some(SharedString::from("Nakui — Event Log")),
52                    ..Default::default()
53                }),
54                ..Default::default()
55            },
56            |_w, cx| cx.new(Explorer::new),
57        )
58        .expect("open window");
59        cx.activate(true);
60    });
61}
62
63/// Estado de la vista. `entries` se reescribe en cada tick (el log
64/// es append-only así que reload completo es seguro y barato hasta
65/// decenas de miles de entries; optimizar a delta cuando duela).
66struct Explorer {
67    log_path: PathBuf,
68    entries: Vec<LogEntry>,
69    error: Option<SharedString>,
70    last_load_ms: u64,
71}
72
73impl Explorer {
74    fn new(cx: &mut Context<Self>) -> Self {
75        let log_path = std::env::var("NAKUI_EVENT_LOG")
76            .ok()
77            .map(PathBuf::from)
78            .unwrap_or_else(|| PathBuf::from("nakui.jsonl"));
79
80        // Loop de refresh.
81        let path_for_loop = log_path.clone();
82        cx.spawn(async move |this, cx| {
83            let timer = cx.background_executor().clone();
84            loop {
85                let started = std::time::Instant::now();
86                let result = load_log(&path_for_loop);
87                let elapsed_ms = started.elapsed().as_millis() as u64;
88                let _ = this.update(cx, |me, cx| {
89                    match result {
90                        Ok(entries) => {
91                            me.entries = entries;
92                            me.error = None;
93                        }
94                        Err(e) => {
95                            me.error = Some(SharedString::from(format!(
96                                "no pude leer {}: {}",
97                                me.log_path.display(),
98                                e
99                            )));
100                        }
101                    }
102                    me.last_load_ms = elapsed_ms;
103                    cx.notify();
104                });
105                timer.timer(REFRESH_INTERVAL).await;
106            }
107        })
108        .detach();
109
110        Self {
111            log_path,
112            entries: Vec::new(),
113            error: None,
114            last_load_ms: 0,
115        }
116    }
117
118    fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) {
119        let mut seeds = 0;
120        let mut morphisms = 0;
121        let mut entity_counts: std::collections::BTreeMap<String, usize> =
122            std::collections::BTreeMap::new();
123        for e in &self.entries {
124            match e {
125                LogEntry::Seed { entity, .. } => {
126                    seeds += 1;
127                    *entity_counts.entry(entity.clone()).or_default() += 1;
128                }
129                LogEntry::Morphism { morphism, .. } => {
130                    morphisms += 1;
131                    *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1;
132                }
133            }
134        }
135        let mut ranked: Vec<_> = entity_counts.into_iter().collect();
136        ranked.sort_by(|a, b| b.1.cmp(&a.1));
137        (seeds, morphisms, ranked)
138    }
139}
140
141fn load_log(path: &std::path::Path) -> Result<Vec<LogEntry>, String> {
142    let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?;
143    log.entries().map_err(|e| format!("read: {e}"))
144}
145
146impl Render for Explorer {
147    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
148        // Colores cromáticos del chrome del app vienen del Theme
149        // global (instalado en main). Los acentos por kind (seed
150        // azul, morphism verde) siguen siendo locales: son señales
151        // semánticas del log, no del chrome.
152        let theme = Theme::global(cx).clone();
153        let bg = theme.bg_app.clone();
154        let text = theme.fg_text;
155        let text_dim = theme.fg_muted;
156        let accent_seed = rgb(0x88c0d0);
157        let accent_morphism = rgb(0xa3be8c);
158
159        let (seed_count, morphism_count, top_breakdown) = self.breakdown();
160
161        let header_text = format!(
162            "Log: {}  ·  {} entries ({} seeds, {} morphisms)  ·  reload {} ms",
163            self.log_path.display(),
164            self.entries.len(),
165            seed_count,
166            morphism_count,
167            self.last_load_ms,
168        );
169
170        let header = div()
171            .px(px(16.))
172            .py(px(12.))
173            .bg(theme.bg_panel.clone())
174            .border_b_1()
175            .border_color(theme.border)
176            .text_color(text)
177            .text_size(px(14.))
178            .child(header_text);
179
180        let breakdown_line = if top_breakdown.is_empty() {
181            String::new()
182        } else {
183            let parts: Vec<String> = top_breakdown
184                .iter()
185                .take(5)
186                .map(|(k, v)| format!("{k}({v})"))
187                .collect();
188            format!("breakdown: {}", parts.join(", "))
189        };
190
191        let breakdown_div = (!breakdown_line.is_empty()).then(|| {
192            div()
193                .px(px(16.))
194                .py(px(6.))
195                .bg(theme.bg_panel_alt.clone())
196                .text_color(text_dim)
197                .text_size(px(11.))
198                .child(breakdown_line)
199        });
200
201        // Banner de error themed: deriva (bg, fg) del Theme actual
202        // según `Banner::Error` + `is_dark`. Padding extra (16/8)
203        // del header preservado via overrides del builder.
204        let error_banner = self.error.as_ref().map(|e| {
205            banner_themed(cx, Banner::Error, e.clone())
206                .px(px(16.))
207                .py(px(8.))
208                .text_size(px(12.))
209        });
210
211        // Renderea las últimas N entries (la timeline crece hacia abajo
212        // en append-order; mostramos las más recientes primero para
213        // que el usuario vea actividad reciente sin scroll).
214        const MAX_VISIBLE: usize = 200;
215        let visible: Vec<_> = self
216            .entries
217            .iter()
218            .rev()
219            .take(MAX_VISIBLE)
220            .collect();
221
222        let cards: Vec<gpui::AnyElement> = visible
223            .iter()
224            .map(|e| match e {
225                LogEntry::Seed {
226                    seq,
227                    entity,
228                    id,
229                    data,
230                    schema_hash,
231                } => {
232                    let data_preview = preview_value(data, 80);
233                    let schema_label = schema_hash
234                        .as_ref()
235                        .map(|h| format!("schema={}", short_hash(h)))
236                        .unwrap_or_else(|| "schema=(legacy)".into());
237                    card_themed(cx)
238                        .border_l_4()
239                        .border_color(accent_seed)
240                        .child(
241                            div()
242                                .flex()
243                                .flex_row()
244                                .gap(px(8.))
245                                .items_center()
246                                .child(
247                                    div()
248                                        .text_color(accent_seed)
249                                        .text_size(px(11.))
250                                        .child(format!("[#{seq} seed]")),
251                                )
252                                .child(
253                                    div()
254                                        .text_color(text)
255                                        .text_size(px(13.))
256                                        .child(entity.clone()),
257                                )
258                                .child(
259                                    div()
260                                        .text_color(text_dim)
261                                        .text_size(px(10.))
262                                        .child(format!("id={}", short_uuid(id))),
263                                ),
264                        )
265                        .child(
266                            div()
267                                .text_color(text_dim)
268                                .text_size(px(11.))
269                                .child(data_preview),
270                        )
271                        .child(
272                            div()
273                                .text_color(text_dim)
274                                .text_size(px(10.))
275                                .child(schema_label),
276                        )
277                        .into_any_element()
278                }
279                LogEntry::Morphism {
280                    seq,
281                    morphism,
282                    inputs,
283                    params,
284                    ops,
285                    schema_hash,
286                } => {
287                    let inputs_line = if inputs.is_empty() {
288                        String::new()
289                    } else {
290                        let parts: Vec<String> = inputs
291                            .iter()
292                            .map(|(name, id)| format!("{name}={}", short_uuid(id)))
293                            .collect();
294                        format!("inputs: {}", parts.join(", "))
295                    };
296                    let params_line = preview_value(params, 80);
297                    let ops_line = format!("{} op(s)", ops.len());
298                    let schema_label = schema_hash
299                        .as_ref()
300                        .map(|h| format!("schema={}", short_hash(h)))
301                        .unwrap_or_else(|| "schema=(legacy)".into());
302                    card_themed(cx)
303                        .border_l_4()
304                        .border_color(accent_morphism)
305                        .child(
306                            div()
307                                .flex()
308                                .flex_row()
309                                .gap(px(8.))
310                                .items_center()
311                                .child(
312                                    div()
313                                        .text_color(accent_morphism)
314                                        .text_size(px(11.))
315                                        .child(format!("[#{seq} morph]")),
316                                )
317                                .child(
318                                    div()
319                                        .text_color(text)
320                                        .text_size(px(13.))
321                                        .child(morphism.clone()),
322                                )
323                                .child(
324                                    div()
325                                        .text_color(text_dim)
326                                        .text_size(px(10.))
327                                        .child(ops_line),
328                                ),
329                        )
330                        .when(!inputs_line.is_empty(), |d| {
331                            d.child(
332                                div()
333                                    .text_color(text_dim)
334                                    .text_size(px(11.))
335                                    .child(inputs_line),
336                            )
337                        })
338                        .when(!params_line.is_empty(), |d| {
339                            d.child(
340                                div()
341                                    .text_color(text_dim)
342                                    .text_size(px(11.))
343                                    .child(format!("params: {params_line}")),
344                            )
345                        })
346                        .child(
347                            div()
348                                .text_color(text_dim)
349                                .text_size(px(10.))
350                                .child(schema_label),
351                        )
352                        .into_any_element()
353                }
354            })
355            .collect();
356
357        let body = div()
358            .flex()
359            .flex_col()
360            .p(px(12.))
361            .overflow_hidden()
362            .children(cards);
363
364        div()
365            .flex()
366            .flex_col()
367            .size_full()
368            .bg(bg)
369            .child(header)
370            .when_some(breakdown_div, |d, b| d.child(b))
371            .when_some(error_banner, |d, b| d.child(b))
372            .child(body)
373    }
374}
375
376// Helpers `short_uuid`, `short_hash`, `preview_value` viven en
377// `yahweh_meta_runtime::format`. Se usan acá via el `use` de arriba.
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::io::Write;
383
384    fn write_sample_log() -> tempfile::NamedTempFile {
385        let mut f = tempfile::NamedTempFile::new().unwrap();
386        // 3 seeds + 2 morphisms, formato canónico de event_log.
387        let lines = [
388            r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#,
389            r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#,
390            r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#,
391            r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#,
392            r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#,
393        ];
394        for l in lines {
395            writeln!(f, "{l}").unwrap();
396        }
397        f.flush().unwrap();
398        f
399    }
400
401    #[test]
402    fn load_log_returns_all_entries_in_order() {
403        let f = write_sample_log();
404        let entries = load_log(f.path()).expect("load");
405        assert_eq!(entries.len(), 5);
406        for (i, e) in entries.iter().enumerate() {
407            assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous");
408        }
409    }
410
411    #[test]
412    fn breakdown_counts_seeds_morphisms_and_buckets() {
413        let f = write_sample_log();
414        let entries = load_log(f.path()).unwrap();
415        let me = Explorer {
416            log_path: f.path().to_path_buf(),
417            entries,
418            error: None,
419            last_load_ms: 0,
420        };
421        let (seeds, morphisms, ranked) = me.breakdown();
422        assert_eq!(seeds, 3);
423        assert_eq!(morphisms, 2);
424        // Buckets esperados: product (2), customer (1), → sale.create (1),
425        // → sale.refund (1).
426        assert_eq!(ranked.len(), 4);
427        let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect();
428        assert_eq!(map.get("product"), Some(&2));
429        assert_eq!(map.get("customer"), Some(&1));
430        assert_eq!(map.get("→ sale.create"), Some(&1));
431        assert_eq!(map.get("→ sale.refund"), Some(&1));
432    }
433
434    #[test]
435    fn load_missing_file_yields_empty_not_error() {
436        // EventLog::open de un archivo inexistente no falla; entries() devuelve [].
437        let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl");
438        let _ = std::fs::remove_file(&path);
439        let result = load_log(&path).expect("missing path is OK per EventLog::open contract");
440        assert!(result.is_empty());
441    }
442
443    // Tests de `short_uuid` / `short_hash` / `preview_value` viven
444    // en `yahweh-meta-runtime::format` tras la migración. Si esos se
445    // vuelven a romper, los tests específicos del crate runtime los
446    // capturan; acá no duplicamos.
447}