gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ 170d1f890abd3566703fc33ab44a1bb743e7e6c1 crates/apps/nakui-ui/src/main.rs Rust · 1269 líneas · 45.4 KB Historia Quién tocó qué crudo

feat(nakui-ui): edit + delete de records (ciclo CRUD completo) 170d1f890 · Sergio ·

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}