tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Sutichaykuna

⎇ 8fdef818cc0524aa4bac39801cefd5960113dcdf crates/apps/nakui-explorer/src/main.rs Rust · 455 siq'ikuna · 17.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

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