⎇ 170d1f890abd3566703fc33ab44a1bb743e7e6c1
crates/apps/nakui-ui/src/main.rs
Historia
Quién tocó qué
crudo
feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
1//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
2//!
3//! Carga módulos desde un directorio (cada módulo = un
4//! `module.json`), monta sidebar con sus menús, y renderea la vista
5//! activa en el panel principal:
6//!
7//! - **List**: tabla de instancias del entity. Botones de acción en
8//! el header (típicamente "Nuevo" → form).
9//! - **Form**: campos editables (con `yahweh-widget-text-input` para
10//! teclado real); al submit, escribe al `MemoryStore` in-process
11//! via `seed_entity` (alta directa) o por morphism (TODO en este
12//! iter).
13//!
14//! Todo el storage es in-memory por ahora — el escenario "save to
15//! disk" se materializa cuando el daemon Nakui exista. La
16//! arquitectura permite swap sin tocar la UI.
17//!
18//! ## Uso
19//!
20//! ```sh
21//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
22//! # default sin env: ./nakui-modules en pwd.
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30 div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
31 Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{replay_into, EventLog, LogEntry};
35use nakui_core::store::{MemoryStore, Store};
36use nakui_ui_schema::{
37 Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
38};
39use serde_json::{json, Value};
40use uuid::Uuid;
41use yahweh_theme::Theme;
42use yahweh_widget_text_input::TextInput;
43
44fn main() {
45 Application::new().run(|cx: &mut App| {
46 // El text input pide Theme::global; instalarlo antes de
47 // crear el window evita que panicee.
48 Theme::install_default(cx);
49
50 let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
51 cx.open_window(
52 WindowOptions {
53 window_bounds: Some(WindowBounds::Windowed(bounds)),
54 titlebar: Some(gpui::TitlebarOptions {
55 title: Some(SharedString::from("Nakui")),
56 ..Default::default()
57 }),
58 ..Default::default()
59 },
60 |_w, cx| cx.new(MetaUi::new),
61 )
62 .expect("open window");
63 cx.activate(true);
64 });
65}
66
67/// Estado del runtime.
68struct MetaUi {
69 /// Módulos cargados, ordenados por id.
70 modules: Vec<Module>,
71 /// Store compartido. Mutado por el submit de los forms.
72 store: Arc<Mutex<MemoryStore>>,
73 /// Event log persistente compartido. Cada `seed_entity` se appende
74 /// acá antes de mutar el store (WAL). `None` si la apertura del
75 /// log falló — en ese caso el runtime degrada a in-memory only y
76 /// loggea un toast informativo.
77 event_log: Option<Arc<Mutex<EventLog>>>,
78 /// (módulo idx, vista key) actualmente activos.
79 active: Option<(usize, String)>,
80 /// Inputs vivos para el form actual: nombre del campo → TextInput.
81 /// Se reemplaza al cambiar de vista (drop de los anteriores).
82 form_inputs: BTreeMap<String, Entity<TextInput>>,
83 /// Si está set, el próximo render del Form pre-llena los inputs
84 /// con los valores del record indicado, y `commit_seed` emite
85 /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
86 /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
87 /// submit exitoso.
88 editing: Option<(String, Uuid)>,
89 /// Mensaje toast al pie (success de submit, error de carga, etc.).
90 toast: Option<SharedString>,
91 /// Si la carga de módulos falló al inicio.
92 load_error: Option<SharedString>,
93}
94
95impl MetaUi {
96 fn new(_cx: &mut Context<Self>) -> Self {
97 let modules_dir = std::env::var("NAKUI_MODULES_DIR")
98 .ok()
99 .map(PathBuf::from)
100 .unwrap_or_else(|| PathBuf::from("nakui-modules"));
101
102 let (modules, mut load_error) =
103 match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
104 Ok(m) => (m, None),
105 Err(e) => (
106 Vec::new(),
107 Some(SharedString::from(format!(
108 "no pude cargar módulos de {}: {e}",
109 modules_dir.display()
110 ))),
111 ),
112 };
113
114 // Persistencia: abrir/crear el event log y hacer replay al
115 // store. Path por env `NAKUI_EVENT_LOG`, default
116 // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el
117 // runtime sigue en modo in-memory (sin persistencia) y el
118 // load_error se acumula al banner.
119 let log_path = std::env::var("NAKUI_EVENT_LOG")
120 .map(PathBuf::from)
121 .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
122 let mut store = MemoryStore::new();
123 let mut initial_toast: Option<SharedString> = None;
124 let event_log = match EventLog::open(&log_path) {
125 Ok(log) => {
126 match replay_into(&log, &mut store) {
127 Ok(()) => {
128 let n = log.next_seq();
129 if n > 0 {
130 initial_toast = Some(SharedString::from(format!(
131 "log {} cargado: {n} evento(s) replayed",
132 log_path.display()
133 )));
134 } else {
135 initial_toast = Some(SharedString::from(format!(
136 "log nuevo en {}",
137 log_path.display()
138 )));
139 }
140 Some(Arc::new(Mutex::new(log)))
141 }
142 Err(e) => {
143 let msg = format!(
144 "replay del log {} falló: {e} — running in-memory",
145 log_path.display()
146 );
147 match &load_error {
148 Some(prev) => {
149 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
150 }
151 None => load_error = Some(SharedString::from(msg)),
152 }
153 None
154 }
155 }
156 }
157 Err(e) => {
158 let msg = format!(
159 "abrir log {}: {e} — running in-memory only",
160 log_path.display()
161 );
162 match &load_error {
163 Some(prev) => {
164 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
165 }
166 None => load_error = Some(SharedString::from(msg)),
167 }
168 None
169 }
170 };
171
172 let active = modules
173 .first()
174 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
175
176 Self {
177 modules,
178 store: Arc::new(Mutex::new(store)),
179 event_log,
180 active,
181 form_inputs: BTreeMap::new(),
182 editing: None,
183 toast: initial_toast,
184 load_error,
185 }
186 }
187
188 /// Cambia la vista activa. Si la nueva vista es un Form, crea
189 /// `TextInput` entities para cada field. Pre-llena con valores
190 /// del record si hay `editing` para esa entity; si no, usa el
191 /// `default` del schema.
192 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
193 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
194 self.active = Some((mod_idx, view_key.clone()));
195 self.toast = None;
196 self.form_inputs = BTreeMap::new();
197 if let Some(module) = self.modules.get(mod_idx) {
198 if let Some(View::Form(form)) = module.views.get(&view_key) {
199 // Snapshot del record si estamos editando esta entity.
200 let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
201 if e == &form.entity {
202 let store = self.store.lock().ok()?;
203 store.load(e, *id)
204 } else {
205 None
206 }
207 });
208 for f in &form.fields {
209 let initial = if let Some(rec) = &editing_record {
210 rec.get(&f.name)
211 .map(value_to_input_text)
212 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
213 } else {
214 f.default.clone().unwrap_or_default()
215 };
216 let input = cx.new(|cx| TextInput::new(initial, cx));
217 self.form_inputs.insert(f.name.clone(), input);
218 }
219 } else {
220 // Cambiar a una view que no es Form invalida el editing
221 // pendiente.
222 self.editing = None;
223 }
224 }
225 cx.notify();
226 }
227
228 /// Inicia un edit del record: setea `editing` y abre la primera
229 /// view de tipo Form del módulo (convención: la del schema).
230 fn open_edit(
231 &mut self,
232 mod_idx: usize,
233 entity: String,
234 id: Uuid,
235 cx: &mut Context<Self>,
236 ) {
237 self.editing = Some((entity.clone(), id));
238 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
239 m.views
240 .iter()
241 .find_map(|(key, v)| match v {
242 View::Form(form) if form.entity == entity => Some(key.clone()),
243 _ => None,
244 })
245 });
246 match form_view_key {
247 Some(key) => self.select_view(mod_idx, key, cx),
248 None => {
249 self.toast = Some(SharedString::from(format!(
250 "no hay form view para entity '{entity}' en este módulo"
251 )));
252 self.editing = None;
253 cx.notify();
254 }
255 }
256 }
257
258 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
259 /// aplica al store via `apply` (no via remove directo, mantiene
260 /// el modelo de "todo cambio post-seed pasa por ops").
261 fn commit_delete(
262 &mut self,
263 entity: &str,
264 id: Uuid,
265 ) -> Result<(), String> {
266 let ops = vec![FieldOp::Delete {
267 entity: entity.to_string(),
268 id,
269 }];
270 if let Some(log_arc) = self.event_log.as_ref() {
271 let mut log = log_arc
272 .lock()
273 .map_err(|_| "log mutex envenenado".to_string())?;
274 let seq = log.next_seq();
275 log.append(LogEntry::Morphism {
276 seq,
277 morphism: "ui.delete_record".into(),
278 inputs: Default::default(),
279 params: json!({ "entity": entity, "id": id.to_string() }),
280 ops: ops.clone(),
281 schema_hash: None,
282 })
283 .map_err(|e| format!("append al log: {e}"))?;
284 }
285 let mut store = self
286 .store
287 .lock()
288 .map_err(|_| "store mutex envenenado".to_string())?;
289 store
290 .apply(&ops)
291 .map_err(|e| format!("apply Delete: {e}"))?;
292 Ok(())
293 }
294
295 /// Aplica una acción (click en menú, botón de form, action de
296 /// list). Mutaciones contra el store ocurren acá.
297 fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
298 let mod_idx = match self.active.as_ref() {
299 Some((i, _)) => *i,
300 None => return,
301 };
302 // Snapshot del editing al entrar — si commit_seed modifica
303 // self.editing antes del toast, el mensaje refleja el modo
304 // correcto.
305 let was_editing = self.editing.is_some();
306 match action {
307 Action::OpenView { view, .. } => {
308 // Salir a otra view cancela el edit pendiente.
309 self.editing = None;
310 self.select_view(mod_idx, view, cx);
311 }
312 Action::SeedEntity { entity, next_view } => {
313 match self.commit_seed(mod_idx, &entity, cx) {
314 Ok(id) => {
315 let action_label = if was_editing { "actualizado" } else { "creado" };
316 self.toast = Some(SharedString::from(format!(
317 "{action_label} {entity} {}",
318 short_uuid(&id)
319 )));
320 // Limpia editing tras un commit exitoso —
321 // el record ya está sincronizado.
322 self.editing = None;
323 if let Some(v) = next_view {
324 self.select_view(mod_idx, v, cx);
325 } else {
326 for input in self.form_inputs.values() {
327 input.update(cx, |inp, cx| inp.set_text("", cx));
328 }
329 }
330 }
331 Err(e) => {
332 self.toast = Some(SharedString::from(format!("error: {e}")));
333 }
334 }
335 cx.notify();
336 }
337 Action::Morphism { name, .. } => {
338 self.toast = Some(SharedString::from(format!(
339 "morphism '{name}': pendiente (requiere manifest nakui)"
340 )));
341 cx.notify();
342 }
343 }
344 }
345
346 /// Construye un Value desde los TextInput vivos y lo seedea al store.
347 fn commit_seed(
348 &mut self,
349 mod_idx: usize,
350 entity: &str,
351 cx: &mut Context<Self>,
352 ) -> Result<Uuid, String> {
353 let module = &self.modules[mod_idx];
354 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
355 Some((_, view_key)) => match module.views.get(view_key) {
356 Some(View::Form(f)) => f.fields.clone(),
357 _ => return Err("la vista activa no es un Form".into()),
358 },
359 None => return Err("ninguna vista activa".into()),
360 };
361 let mut obj = serde_json::Map::new();
362 for f in &spec_fields {
363 let raw = self
364 .form_inputs
365 .get(&f.name)
366 .map(|input| input.read(cx).text().to_string())
367 .unwrap_or_default();
368 if f.required && raw.trim().is_empty() {
369 return Err(format!("campo '{}' es obligatorio", f.label));
370 }
371 if raw.is_empty() && !f.required {
372 continue;
373 }
374 let value = parse_field_value(f.kind, &raw)
375 .map_err(|e| format!("campo '{}': {e}", f.label))?;
376 obj.insert(f.name.clone(), value);
377 }
378 // Ramificación: si `editing` está set para esta entity, es un
379 // edit de un record existente — emitimos Morphism con un
380 // FieldOp::Set por cada campo del form (sobreescribe). Si no,
381 // es alta nueva — emitimos Seed con UUID fresco.
382 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
383
384 if let Some((_, id)) = editing_match {
385 // EDIT path: Morphism { ui.edit_record, ops: [Set...] }
386 let ops: Vec<FieldOp> = obj
387 .iter()
388 .map(|(field, value)| FieldOp::Set {
389 path: FieldPath {
390 entity: entity.to_string(),
391 id,
392 field: field.clone(),
393 },
394 value: value.clone(),
395 })
396 .collect();
397
398 if let Some(log_arc) = self.event_log.as_ref() {
399 let mut log = log_arc
400 .lock()
401 .map_err(|_| "log mutex envenenado".to_string())?;
402 let seq = log.next_seq();
403 log.append(LogEntry::Morphism {
404 seq,
405 morphism: "ui.edit_record".into(),
406 inputs: Default::default(),
407 params: json!({
408 "entity": entity,
409 "id": id.to_string(),
410 "fields": Value::Object(obj.clone()),
411 }),
412 ops: ops.clone(),
413 schema_hash: None,
414 })
415 .map_err(|e| format!("append al log: {e}"))?;
416 }
417 let mut store = self
418 .store
419 .lock()
420 .map_err(|_| "store mutex envenenado".to_string())?;
421 store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
422 Ok(id)
423 } else {
424 // SEED path: alta nueva.
425 let id = Uuid::new_v4();
426 let data = Value::Object(obj);
427 if let Some(log_arc) = self.event_log.as_ref() {
428 let mut log = log_arc
429 .lock()
430 .map_err(|_| "log mutex envenenado".to_string())?;
431 let seq = log.next_seq();
432 log.append(LogEntry::Seed {
433 seq,
434 entity: entity.to_string(),
435 id,
436 data: data.clone(),
437 schema_hash: None,
438 })
439 .map_err(|e| format!("append al log: {e}"))?;
440 }
441 let mut store = self
442 .store
443 .lock()
444 .map_err(|_| "store mutex envenenado".to_string())?;
445 store.seed(entity, id, data);
446 Ok(id)
447 }
448 }
449
450 /// Snapshot ordenado de records de una entity.
451 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
452 let store = match self.store.lock() {
453 Ok(g) => g,
454 Err(_) => return Vec::new(),
455 };
456 let it = match store.iter() {
457 Ok(i) => i,
458 Err(_) => return Vec::new(),
459 };
460 it.filter(|(e, _, _)| e == entity)
461 .map(|(_, id, v)| (id, v))
462 .collect()
463 }
464}
465
466fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
467 match kind {
468 FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
469 FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
470 "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
471 "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
472 other => Err(format!("'{other}' no es booleano")),
473 },
474 FieldKind::Number => {
475 if let Ok(i) = raw.parse::<i64>() {
476 Ok(json!(i))
477 } else if let Ok(f) = raw.parse::<f64>() {
478 Ok(json!(f))
479 } else {
480 Err(format!("'{raw}' no es número"))
481 }
482 }
483 }
484}
485
486fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
487 let mut cur = v;
488 for seg in path.split('.') {
489 cur = cur.get(seg)?;
490 }
491 Some(cur)
492}
493
494fn render_value(v: Option<&Value>) -> String {
495 match v {
496 None | Some(Value::Null) => String::new(),
497 Some(Value::String(s)) => s.clone(),
498 Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
499 Some(Value::Number(n)) => n.to_string(),
500 Some(other) => other.to_string(),
501 }
502}
503
504/// Conversión inversa a `parse_field_value`: del JSON al texto raw
505/// que un input puede tomar y volver a parsearse igual al submit.
506/// Usado para pre-llenar inputs en modo edit.
507fn value_to_input_text(v: &Value) -> String {
508 match v {
509 Value::Null => String::new(),
510 Value::String(s) => s.clone(),
511 Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
512 Value::Number(n) => n.to_string(),
513 other => other.to_string(),
514 }
515}
516
517fn short_uuid(id: &Uuid) -> String {
518 id.to_string().chars().take(8).collect()
519}
520
521impl Render for MetaUi {
522 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
523 let bg = gpui::rgb(0x14171c);
524 let panel = gpui::rgb(0x1d2128);
525 let border = gpui::rgb(0x2a2f38);
526 let text = gpui::rgb(0xe6e8ec);
527 let text_dim = gpui::rgb(0x9ba1ad);
528 let accent = gpui::rgb(0x88c0d0);
529 let accent_active = gpui::rgb(0xa3be8c);
530
531 let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
532 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
533 let toast_div = self.toast.as_ref().map(|t| {
534 div()
535 .px(px(12.))
536 .py(px(6.))
537 .bg(gpui::rgb(0x2d3a2a))
538 .text_color(gpui::rgb(0xc0e0a0))
539 .text_size(px(11.))
540 .child(t.clone())
541 });
542 let error_banner = self.load_error.as_ref().map(|e| {
543 div()
544 .px(px(12.))
545 .py(px(6.))
546 .bg(gpui::rgb(0x4a2020))
547 .text_color(gpui::rgb(0xffd0d0))
548 .text_size(px(11.))
549 .child(e.clone())
550 });
551
552 div()
553 .flex()
554 .flex_col()
555 .size_full()
556 .bg(bg)
557 .when_some(error_banner, |d, b| d.child(b))
558 .child(
559 div()
560 .flex()
561 .flex_row()
562 .flex_grow()
563 .child(sidebar)
564 .child(main_panel),
565 )
566 .when_some(toast_div, |d, t| d.child(t))
567 }
568}
569
570impl MetaUi {
571 fn render_sidebar(
572 &self,
573 cx: &mut Context<Self>,
574 panel: gpui::Rgba,
575 border: gpui::Rgba,
576 text: gpui::Rgba,
577 text_dim: gpui::Rgba,
578 accent_active: gpui::Rgba,
579 ) -> gpui::Div {
580 let mut sidebar = div()
581 .w(px(240.))
582 .h_full()
583 .bg(panel)
584 .border_r_1()
585 .border_color(border)
586 .flex()
587 .flex_col();
588
589 sidebar = sidebar.child(
590 div()
591 .px(px(12.))
592 .py(px(10.))
593 .text_color(text)
594 .text_size(px(13.))
595 .child("Nakui"),
596 );
597
598 if self.modules.is_empty() {
599 return sidebar.child(
600 div()
601 .px(px(12.))
602 .py(px(8.))
603 .text_color(text_dim)
604 .text_size(px(11.))
605 .child("(no hay módulos cargados)"),
606 );
607 }
608
609 // Snapshot del active para evitar borrow del self adentro de la closure.
610 let active_snapshot = self.active.clone();
611
612 for (mod_idx, m) in self.modules.iter().enumerate() {
613 sidebar = sidebar.child(
614 div()
615 .px(px(12.))
616 .py(px(8.))
617 .border_t_1()
618 .border_color(border)
619 .text_color(text)
620 .text_size(px(12.))
621 .child(m.label.clone()),
622 );
623
624 for item in &m.menu {
625 let is_active = active_snapshot
626 .as_ref()
627 .map(|(i, v)| *i == mod_idx && v == &item.view)
628 .unwrap_or(false);
629 let label = item
630 .icon
631 .as_deref()
632 .map(|ic| format!("{ic} {}", item.label))
633 .unwrap_or_else(|| item.label.clone());
634
635 let view_key = item.view.clone();
636 sidebar = sidebar.child(
637 div()
638 .id(SharedString::from(format!(
639 "menu-{}-{}",
640 mod_idx, item.view
641 )))
642 .px(px(20.))
643 .py(px(6.))
644 .text_size(px(12.))
645 .text_color(if is_active { accent_active } else { text_dim })
646 .when(is_active, |d| {
647 d.bg(gpui::rgb(0x232a36)).text_color(text)
648 })
649 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
650 .child(label)
651 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
652 this.select_view(mod_idx, view_key.clone(), cx);
653 })),
654 );
655 }
656 }
657 sidebar
658 }
659
660 fn render_main(
661 &mut self,
662 cx: &mut Context<Self>,
663 panel: gpui::Rgba,
664 border: gpui::Rgba,
665 text: gpui::Rgba,
666 text_dim: gpui::Rgba,
667 accent: gpui::Rgba,
668 ) -> gpui::Div {
669 let main = div()
670 .flex_grow()
671 .h_full()
672 .bg(panel)
673 .flex()
674 .flex_col()
675 .p(px(16.));
676
677 let (mod_idx, view_key) = match &self.active {
678 Some(a) => (a.0, a.1.clone()),
679 None => {
680 return main.child(
681 div()
682 .text_color(text_dim)
683 .child("Seleccioná un menú a la izquierda."),
684 );
685 }
686 };
687
688 let view = match self
689 .modules
690 .get(mod_idx)
691 .and_then(|m| m.views.get(&view_key))
692 {
693 Some(v) => v.clone(),
694 None => {
695 return main.child(
696 div()
697 .text_color(text_dim)
698 .child(format!("Vista no encontrada: {view_key}")),
699 );
700 }
701 };
702
703 match view {
704 View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
705 View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
706 }
707 }
708
709 #[allow(clippy::too_many_arguments)]
710 fn render_list(
711 &self,
712 cx: &mut Context<Self>,
713 mut main: gpui::Div,
714 lv: &ListView,
715 mod_idx: usize,
716 border: gpui::Rgba,
717 text: gpui::Rgba,
718 text_dim: gpui::Rgba,
719 accent: gpui::Rgba,
720 ) -> gpui::Div {
721 let mut header = div()
722 .flex()
723 .flex_row()
724 .items_center()
725 .gap(px(12.))
726 .mb(px(12.))
727 .child(
728 div()
729 .text_color(text)
730 .text_size(px(18.))
731 .child(lv.title.clone()),
732 );
733 for (idx, action) in lv.actions.iter().enumerate() {
734 let label = match action {
735 Action::OpenView { label, view } => {
736 label.clone().unwrap_or_else(|| format!("→ {view}"))
737 }
738 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
739 Action::Morphism { name, .. } => format!("⚡ {name}"),
740 };
741 let action_clone = action.clone();
742 header = header.child(
743 div()
744 .id(SharedString::from(format!(
745 "list-action-{mod_idx}-{idx}"
746 )))
747 .px(px(10.))
748 .py(px(4.))
749 .bg(gpui::rgb(0x232a36))
750 .text_color(accent)
751 .text_size(px(11.))
752 .rounded(px(3.))
753 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
754 .child(label)
755 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
756 this.apply_action(action_clone.clone(), cx);
757 })),
758 );
759 }
760 main = main.child(header);
761
762 let rows = self.list_rows(&lv.entity);
763 let total = rows.len();
764
765 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
766 let mut col_header = div()
767 .flex()
768 .flex_row()
769 .py(px(6.))
770 .border_b_1()
771 .border_color(border)
772 .text_color(text_dim)
773 .text_size(px(11.));
774 for c in &lv.columns {
775 let frac = c.weight / total_weight;
776 col_header = col_header.child(
777 div()
778 .flex_grow()
779 .flex_basis(px(100. * frac))
780 .child(c.label.clone()),
781 );
782 }
783 col_header = col_header
784 .child(div().w(px(80.)).text_color(text_dim).child("id"))
785 .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
786 main = main.child(col_header);
787
788 let entity_name = lv.entity.clone();
789 for (id, value) in &rows {
790 let id_copy = *id;
791 let entity_for_edit = entity_name.clone();
792 let entity_for_delete = entity_name.clone();
793 let mut row = div()
794 .flex()
795 .flex_row()
796 .py(px(6.))
797 .border_b_1()
798 .border_color(gpui::rgb(0x232a36))
799 .text_color(text)
800 .text_size(px(12.));
801 for c in &lv.columns {
802 let frac = c.weight / total_weight;
803 let v = lookup_field(value, &c.field);
804 row = row.child(
805 div()
806 .flex_grow()
807 .flex_basis(px(100. * frac))
808 .child(render_value(v)),
809 );
810 }
811 row = row.child(
812 div()
813 .w(px(80.))
814 .text_color(text_dim)
815 .text_size(px(11.))
816 .child(short_uuid(id)),
817 );
818 // Acciones: ✎ edit + ✕ delete por fila.
819 row = row.child(
820 div()
821 .w(px(70.))
822 .flex()
823 .flex_row()
824 .gap(px(4.))
825 .child(
826 div()
827 .id(SharedString::from(format!(
828 "row-edit-{mod_idx}-{id_copy}"
829 )))
830 .px(px(6.))
831 .text_color(accent)
832 .text_size(px(13.))
833 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
834 .child("✎")
835 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
836 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
837 })),
838 )
839 .child(
840 div()
841 .id(SharedString::from(format!(
842 "row-del-{mod_idx}-{id_copy}"
843 )))
844 .px(px(6.))
845 .text_color(gpui::rgb(0xd07070))
846 .text_size(px(13.))
847 .hover(|d| d.bg(gpui::rgb(0x4a2020)))
848 .child("✕")
849 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
850 match this.commit_delete(&entity_for_delete, id_copy) {
851 Ok(()) => {
852 this.toast = Some(SharedString::from(format!(
853 "borrado {entity_for_delete} {}",
854 short_uuid(&id_copy)
855 )));
856 }
857 Err(e) => {
858 this.toast = Some(SharedString::from(format!(
859 "error borrando: {e}"
860 )));
861 }
862 }
863 cx.notify();
864 })),
865 ),
866 );
867 main = main.child(row);
868 }
869
870 if rows.is_empty() {
871 main = main.child(
872 div()
873 .py(px(12.))
874 .text_color(text_dim)
875 .text_size(px(12.))
876 .child(format!("(sin {})", lv.entity)),
877 );
878 } else {
879 main = main.child(
880 div()
881 .mt(px(8.))
882 .text_color(text_dim)
883 .text_size(px(11.))
884 .child(format!("{total} fila(s)")),
885 );
886 }
887
888 main
889 }
890
891 #[allow(clippy::too_many_arguments)]
892 fn render_form(
893 &self,
894 cx: &mut Context<Self>,
895 mut main: gpui::Div,
896 fv: &FormView,
897 mod_idx: usize,
898 _border: gpui::Rgba,
899 text: gpui::Rgba,
900 text_dim: gpui::Rgba,
901 accent: gpui::Rgba,
902 ) -> gpui::Div {
903 // En modo edit, el título refleja eso para que el user no
904 // se confunda creyendo que hace alta nueva.
905 let title = match self.editing.as_ref() {
906 Some((e, id)) if e == &fv.entity => {
907 format!("Editar {} {}", fv.entity, short_uuid(id))
908 }
909 _ => fv.title.clone(),
910 };
911 main = main.child(
912 div()
913 .text_color(text)
914 .text_size(px(18.))
915 .mb(px(12.))
916 .child(title),
917 );
918 for f in &fv.fields {
919 let label = if f.required {
920 format!("{} *", f.label)
921 } else {
922 f.label.clone()
923 };
924
925 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
926 div()
927 .text_color(text_dim)
928 .text_size(px(11.))
929 .mb(px(2.))
930 .child(label),
931 );
932
933 // Mount del TextInput vivo (creado en select_view).
934 if let Some(input) = self.form_inputs.get(&f.name) {
935 field_box = field_box.child(input.clone());
936 } else {
937 // No debería pasar — select_view crea inputs por cada
938 // field. Fallback display estático por seguridad.
939 field_box = field_box.child(
940 div()
941 .px(px(8.))
942 .py(px(6.))
943 .bg(gpui::rgb(0x171a20))
944 .text_color(text_dim)
945 .child("(input no inicializado)"),
946 );
947 }
948
949 if let Some(help) = &f.help {
950 field_box = field_box.child(
951 div()
952 .mt(px(2.))
953 .text_color(text_dim)
954 .text_size(px(10.))
955 .child(help.clone()),
956 );
957 }
958 main = main.child(field_box);
959 }
960
961 let editing_this = matches!(
962 self.editing.as_ref(),
963 Some((e, _)) if e == &fv.entity
964 );
965 let submit_label = match &fv.on_submit {
966 Action::SeedEntity { entity, .. } => {
967 if editing_this {
968 format!("Guardar cambios en {entity}")
969 } else {
970 format!("Crear {entity}")
971 }
972 }
973 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
974 Action::OpenView { label, view } => {
975 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
976 }
977 };
978 let submit_action = fv.on_submit.clone();
979 main = main.child(
980 div().mt(px(12.)).child(
981 div()
982 .id(SharedString::from(format!("form-submit-{mod_idx}")))
983 .px(px(12.))
984 .py(px(6.))
985 .bg(gpui::rgb(0x2c3540))
986 .text_color(accent)
987 .text_size(px(12.))
988 .rounded(px(3.))
989 .hover(|d| d.bg(gpui::rgb(0x3a4555)))
990 .child(submit_label)
991 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
992 this.apply_action(submit_action.clone(), cx);
993 })),
994 ),
995 );
996
997 main = main.child(
998 div()
999 .mt(px(20.))
1000 .text_color(text_dim)
1001 .text_size(px(10.))
1002 .child(
1003 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1004 usá el botón. Backspace borra el último carácter.",
1005 ),
1006 );
1007 main
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014
1015 #[test]
1016 fn parse_field_text_returns_string() {
1017 assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
1018 }
1019
1020 #[test]
1021 fn parse_field_number_int_then_float() {
1022 assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
1023 assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
1024 }
1025
1026 #[test]
1027 fn parse_field_number_invalid_errors() {
1028 assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
1029 }
1030
1031 #[test]
1032 fn parse_field_boolean_variants() {
1033 assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
1034 assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
1035 assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
1036 assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
1037 assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
1038 }
1039
1040 #[test]
1041 fn lookup_field_simple_and_nested() {
1042 let v = json!({
1043 "name": "Acme",
1044 "address": { "city": "Bogotá", "country": "CO" }
1045 });
1046 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1047 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1048 assert!(lookup_field(&v, "missing").is_none());
1049 assert!(lookup_field(&v, "address.zipcode").is_none());
1050 }
1051
1052 #[test]
1053 fn render_value_handles_null_string_bool() {
1054 assert_eq!(render_value(None), "");
1055 assert_eq!(render_value(Some(&json!(null))), "");
1056 assert_eq!(render_value(Some(&json!("x"))), "x");
1057 assert_eq!(render_value(Some(&json!(true))), "✓");
1058 assert_eq!(render_value(Some(&json!(false))), "✗");
1059 assert_eq!(render_value(Some(&json!(42))), "42");
1060 }
1061
1062 #[test]
1063 fn value_to_input_text_inverse_of_parse() {
1064 // text → text
1065 assert_eq!(value_to_input_text(&json!("hola")), "hola");
1066 // bool → "true"/"false" (parse_field_value lo acepta)
1067 assert_eq!(value_to_input_text(&json!(true)), "true");
1068 assert_eq!(value_to_input_text(&json!(false)), "false");
1069 // number → string
1070 assert_eq!(value_to_input_text(&json!(42)), "42");
1071 assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
1072 // null → ""
1073 assert_eq!(value_to_input_text(&json!(null)), "");
1074 }
1075
1076 #[test]
1077 fn value_to_input_then_parse_round_trip() {
1078 // El round-trip es la propiedad fundamental: edit → text →
1079 // parse → mismo Value (modulo casts numéricos).
1080 let cases = vec![
1081 (FieldKind::Text, json!("hola")),
1082 (FieldKind::Boolean, json!(true)),
1083 (FieldKind::Boolean, json!(false)),
1084 (FieldKind::Number, json!(42)),
1085 ];
1086 for (kind, original) in cases {
1087 let text = value_to_input_text(&original);
1088 let parsed = parse_field_value(kind, &text).unwrap();
1089 assert_eq!(
1090 parsed, original,
1091 "round-trip text→parse falló para {original:?}"
1092 );
1093 }
1094 }
1095
1096 /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
1097 /// abrimos con `EventLog::open` + `replay_into`, y verificamos
1098 /// que el `MemoryStore` queda con esos records aplicados.
1099 /// Esto reproduce el flujo del startup de `MetaUi::new` sin
1100 /// necesitar GPUI.
1101 #[test]
1102 fn event_log_replay_restores_memory_store() {
1103 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1104 use nakui_core::store::{MemoryStore, Store};
1105
1106 let tmp = tempfile::NamedTempFile::new().unwrap();
1107 let path = tmp.path().to_path_buf();
1108 // Cerramos el handle de tempfile pero conservamos el path
1109 // para que EventLog pueda re-abrir.
1110 drop(tmp);
1111
1112 // Escribimos dos seeds via EventLog::append.
1113 let id_a = Uuid::new_v4();
1114 let id_b = Uuid::new_v4();
1115 {
1116 let mut log = EventLog::open(&path).unwrap();
1117 log.append(LogEntry::Seed {
1118 seq: 0,
1119 entity: "customer".into(),
1120 id: id_a,
1121 data: json!({"name": "Acme"}),
1122 schema_hash: None,
1123 })
1124 .unwrap();
1125 log.append(LogEntry::Seed {
1126 seq: 1,
1127 entity: "customer".into(),
1128 id: id_b,
1129 data: json!({"name": "Globex"}),
1130 schema_hash: None,
1131 })
1132 .unwrap();
1133 }
1134
1135 // Re-abrir + replay (simula startup de MetaUi).
1136 let log = EventLog::open(&path).unwrap();
1137 assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
1138 let mut store = MemoryStore::new();
1139 replay_into(&log, &mut store).unwrap();
1140
1141 // Verificar que ambos records están en el store.
1142 assert_eq!(
1143 store.load("customer", id_a),
1144 Some(json!({"name": "Acme"})),
1145 "Acme debería estar tras replay"
1146 );
1147 assert_eq!(
1148 store.load("customer", id_b),
1149 Some(json!({"name": "Globex"})),
1150 "Globex debería estar tras replay"
1151 );
1152
1153 let _ = std::fs::remove_file(&path);
1154 }
1155
1156 /// E2E del ciclo CRUD vía log:
1157 /// 1. Seed un record.
1158 /// 2. Morphism con Set ops (edit) — sobreescribe campos.
1159 /// 3. Morphism con Delete op — borra el record.
1160 /// 4. Replay desde cero: el store queda como tras el delete (vacío).
1161 #[test]
1162 fn event_log_replay_handles_full_crud_cycle() {
1163 use nakui_core::delta::{FieldOp, FieldPath};
1164 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1165 use nakui_core::store::{MemoryStore, Store};
1166
1167 let tmp = tempfile::NamedTempFile::new().unwrap();
1168 let path = tmp.path().to_path_buf();
1169 drop(tmp);
1170
1171 let id = Uuid::new_v4();
1172
1173 // 1. Escribir 3 entries: seed, edit, delete.
1174 {
1175 let mut log = EventLog::open(&path).unwrap();
1176 log.append(LogEntry::Seed {
1177 seq: 0,
1178 entity: "customer".into(),
1179 id,
1180 data: json!({"name": "Acme", "active": true}),
1181 schema_hash: None,
1182 })
1183 .unwrap();
1184 log.append(LogEntry::Morphism {
1185 seq: 1,
1186 morphism: "ui.edit_record".into(),
1187 inputs: Default::default(),
1188 params: json!({}),
1189 ops: vec![
1190 FieldOp::Set {
1191 path: FieldPath {
1192 entity: "customer".into(),
1193 id,
1194 field: "name".into(),
1195 },
1196 value: json!("Acme S.A."),
1197 },
1198 FieldOp::Set {
1199 path: FieldPath {
1200 entity: "customer".into(),
1201 id,
1202 field: "active".into(),
1203 },
1204 value: json!(false),
1205 },
1206 ],
1207 schema_hash: None,
1208 })
1209 .unwrap();
1210 log.append(LogEntry::Morphism {
1211 seq: 2,
1212 morphism: "ui.delete_record".into(),
1213 inputs: Default::default(),
1214 params: json!({}),
1215 ops: vec![FieldOp::Delete {
1216 entity: "customer".into(),
1217 id,
1218 }],
1219 schema_hash: None,
1220 })
1221 .unwrap();
1222 }
1223
1224 // 2. Replay desde cero — debe terminar con store vacío
1225 // (el delete fue el último op).
1226 let log = EventLog::open(&path).unwrap();
1227 let mut store = MemoryStore::new();
1228 replay_into(&log, &mut store).unwrap();
1229 assert_eq!(
1230 store.load("customer", id),
1231 None,
1232 "tras seed + edit + delete, el record no debería existir"
1233 );
1234
1235 // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
1236 // record debería tener los valores editados.
1237 // (Construimos un store fresh y aplicamos sólo seq 0 y 1
1238 // a mano para verificar.)
1239 let mut store_partial = MemoryStore::new();
1240 store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
1241 store_partial
1242 .apply(&[
1243 FieldOp::Set {
1244 path: FieldPath {
1245 entity: "customer".into(),
1246 id,
1247 field: "name".into(),
1248 },
1249 value: json!("Acme S.A."),
1250 },
1251 FieldOp::Set {
1252 path: FieldPath {
1253 entity: "customer".into(),
1254 id,
1255 field: "active".into(),
1256 },
1257 value: json!(false),
1258 },
1259 ])
1260 .unwrap();
1261 assert_eq!(
1262 store_partial.load("customer", id),
1263 Some(json!({"name": "Acme S.A.", "active": false})),
1264 "tras seed + edit, el record debería tener los nuevos valores"
1265 );
1266
1267 let _ = std::fs::remove_file(&path);
1268 }
1269}