⎇ 8fdef818cc0524aa4bac39801cefd5960113dcdf
crates/apps/nakui-explorer/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(yahweh-widget-theme-switcher): control para ciclar themes en runtime
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}