⎇ 9632d8c4a7920e3f1cbe57d84dadae5527fa1211
crates/apps/nakui-explorer/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(yahweh): theme integration en banner + card + nakui-explorer consume themed
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}