tawasuyu

tawasuyu/brahman

Rust 230TOML 69JSON 14HTML 12901 Definitions

⎇ 3e4278d76625191a37bd37b57c333fd5431ac508 crates/modules/ui_engine/widgets/meta-form/src/lib.rs Rust · 1204 lines · 45.2 KB History Who touched what raw

feat(yahweh): MockBackend público + tests E2E del widget con TestAppContext 3e4278d76 · Sergio ·

1//! `yahweh-widget-meta-form` — widget GPUI de metainterfaz.
2//!
3//! Renderea cualquier conjunto de [`yahweh_meta_schema::Module`] sobre
4//! cualquier impl de [`yahweh_meta_runtime::MetaBackend`]: sidebar con
5//! menús, list/form views, modal de delete, EntityRef selector.
6//!
7//! El widget es **app-agnostic**: no asume Nakui, ni storage
8//! particular, ni lifecycle de bootstrapping. El binario que lo
9//! consume:
10//!
11//! 1. Carga sus módulos (típicamente via `brahman-cards`).
12//! 2. Construye su backend concreto (typicamente wireado a un
13//!    store/log/executor de su stack).
14//! 3. Crea `MetaApp::new(modules, backend, initial_toast,
15//!    initial_error, cx)` y lo monta como root view de GPUI.
16//!
17//! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario
18//! shell que provee `NakuiBackend`).
19
20#![forbid(unsafe_code)]
21
22use std::collections::BTreeMap;
23
24use gpui::{
25    div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render,
26    SharedString, Window,
27};
28
29use serde_json::Value;
30use uuid::Uuid;
31use yahweh_meta_runtime::{
32    compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
33    render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
34    MetaBackend, WriteOutcome,
35};
36use yahweh_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
37use yahweh_widget_text_input::TextInput;
38
39/// Estado del runtime de UI. Toda la persistencia/ejecución está
40/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
41/// state y el schema de los módulos.
42///
43/// Genérico sobre el backend `B`. Un binario decide qué backend
44/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
45pub struct MetaApp<B: MetaBackend> {
46    /// Módulos cargados, ordenados por id.
47    modules: Vec<Module>,
48    /// Backend que ejecuta seed/update/delete/morphism.
49    backend: B,
50    /// (módulo idx, vista key) actualmente activos.
51    active: Option<(usize, String)>,
52    /// Inputs vivos para el form actual: nombre del campo → TextInput.
53    /// Se reemplaza al cambiar de vista (drop de los anteriores).
54    form_inputs: BTreeMap<String, Entity<TextInput>>,
55    /// Si está set, el próximo render del Form pre-llena los inputs
56    /// con los valores del record indicado, y `commit_seed` emite
57    /// un `update` (no un seed nuevo). Limpia al cambiar de view o
58    /// tras submit exitoso.
59    editing: Option<(String, Uuid)>,
60    /// Si está set, el banner modal de confirmación de delete está
61    /// activo: `(entity, id)` del record que el usuario marcó para
62    /// borrar. Permanece hasta que el usuario click [Confirmar]
63    /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
64    /// Navegación a otra view también cancela.
65    pending_delete: Option<(String, Uuid)>,
66    /// Mensaje toast al pie (success de submit, error de carga, etc.).
67    toast: Option<SharedString>,
68    /// Si la carga de módulos falló al inicio.
69    load_error: Option<SharedString>,
70}
71
72impl<B: MetaBackend> MetaApp<B> {
73    /// Constructor del widget. El caller pre-construye sus módulos +
74    /// backend + cualquier mensaje inicial que quiera mostrar al
75    /// usuario al abrir (típicamente el toast del replay y el error
76    /// banner de cosas que fallaron de bootstrap).
77    ///
78    /// La active view default es la primera entry del menú del
79    /// primer módulo (orden lexicográfico por id).
80    pub fn new(
81        modules: Vec<Module>,
82        backend: B,
83        initial_toast: Option<String>,
84        initial_error: Option<String>,
85        _cx: &mut Context<Self>,
86    ) -> Self {
87        let active = modules
88            .first()
89            .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
90        Self {
91            modules,
92            backend,
93            active,
94            form_inputs: BTreeMap::new(),
95            editing: None,
96            pending_delete: None,
97            toast: initial_toast.map(SharedString::from),
98            load_error: initial_error.map(SharedString::from),
99        }
100    }
101
102    /// Cambia la vista activa. Si la nueva vista es un Form, crea
103    /// `TextInput` entities para cada field. Pre-llena con valores
104    /// del record si hay `editing` para esa entity; si no, usa el
105    /// `default` del schema.
106    /// Drop de los inputs anteriores ocurre al sobreescribir el map.
107    fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
108        self.active = Some((mod_idx, view_key.clone()));
109        self.toast = None;
110        // Navegar a otra view cancela cualquier delete pendiente:
111        // el record marcado puede no estar visible en la nueva view.
112        self.pending_delete = None;
113        self.form_inputs = BTreeMap::new();
114        if let Some(module) = self.modules.get(mod_idx) {
115            if let Some(View::Form(form)) = module.views.get(&view_key) {
116                // Snapshot del record si estamos editando esta entity.
117                let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
118                    if e == &form.entity {
119                        self.backend.load_record(e, *id)
120                    } else {
121                        None
122                    }
123                });
124                for f in &form.fields {
125                    let initial = if let Some(rec) = &editing_record {
126                        rec.get(&f.name)
127                            .map(value_to_input_text)
128                            .unwrap_or_else(|| f.default.clone().unwrap_or_default())
129                    } else {
130                        f.default.clone().unwrap_or_default()
131                    };
132                    let input = cx.new(|cx| TextInput::new(initial, cx));
133                    self.form_inputs.insert(f.name.clone(), input);
134                }
135            } else {
136                // Cambiar a una view que no es Form invalida el editing
137                // pendiente.
138                self.editing = None;
139            }
140        }
141        cx.notify();
142    }
143
144    /// Inicia un edit del record: setea `editing` y abre la primera
145    /// view de tipo Form del módulo (convención: la del schema).
146    fn open_edit(
147        &mut self,
148        mod_idx: usize,
149        entity: String,
150        id: Uuid,
151        cx: &mut Context<Self>,
152    ) {
153        self.editing = Some((entity.clone(), id));
154        let form_view_key = self.modules.get(mod_idx).and_then(|m| {
155            m.views
156                .iter()
157                .find_map(|(key, v)| match v {
158                    View::Form(form) if form.entity == entity => Some(key.clone()),
159                    _ => None,
160                })
161        });
162        match form_view_key {
163            Some(key) => self.select_view(mod_idx, key, cx),
164            None => {
165                self.toast = Some(SharedString::from(format!(
166                    "no hay form view para entity '{entity}' en este módulo"
167                )));
168                self.editing = None;
169                cx.notify();
170            }
171        }
172    }
173
174    /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
175    /// aplica al store via `apply` (no via remove directo, mantiene
176    /// el modelo de "todo cambio post-seed pasa por ops").
177    /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
178    /// del backend (incluye eventual `post_status` del compact tick).
179    fn commit_delete(
180        &mut self,
181        entity: &str,
182        id: Uuid,
183    ) -> Result<WriteOutcome, String> {
184        self.backend.delete(entity, id)
185    }
186
187    /// Aplica una acción (click en menú, botón de form, action de
188    /// list). Mutaciones contra el backend ocurren acá.
189    pub fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
190        let mod_idx = match self.active.as_ref() {
191            Some((i, _)) => *i,
192            None => return,
193        };
194        match action {
195            Action::OpenView { view, .. } => {
196                // Salir a otra view cancela el edit pendiente.
197                self.editing = None;
198                self.select_view(mod_idx, view, cx);
199            }
200            Action::SeedEntity { entity, next_view } => {
201                let was_editing = self.editing.is_some();
202                match self.commit_seed(mod_idx, &entity, cx) {
203                    Ok(outcome) => {
204                        let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
205                        self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
206                        // Limpia editing tras un commit exitoso —
207                        // el record ya está sincronizado (incluso
208                        // un NoChange cierra el modo edit).
209                        self.editing = None;
210                        if let Some(v) = next_view {
211                            self.select_view(mod_idx, v, cx);
212                        } else {
213                            for input in self.form_inputs.values() {
214                                input.update(cx, |inp, cx| inp.set_text("", cx));
215                            }
216                        }
217                    }
218                    Err(e) => {
219                        self.toast = Some(SharedString::from(format!("error: {e}")));
220                    }
221                }
222                cx.notify();
223            }
224            Action::Morphism {
225                name,
226                inputs,
227                params,
228                next_view,
229            } => {
230                match self.commit_morphism(mod_idx, &name, &inputs, &params, cx) {
231                    Ok(outcome) => {
232                        let base = format!(
233                            "morphism '{name}' OK ({} op(s) aplicadas)",
234                            outcome.changed
235                        );
236                        self.toast = Some(append_compact_msg(base, outcome.post_status));
237                        if let Some(v) = next_view {
238                            self.select_view(mod_idx, v, cx);
239                        }
240                    }
241                    Err(e) => {
242                        self.toast = Some(SharedString::from(format!(
243                            "morphism '{name}' falló: {e}"
244                        )));
245                    }
246                }
247                cx.notify();
248            }
249        }
250    }
251
252    /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
253    /// + params (Value object) leyendo los TextInput del form.
254    fn commit_morphism(
255        &mut self,
256        mod_idx: usize,
257        morphism: &str,
258        inputs_map: &BTreeMap<String, String>,
259        params_fields: &[String],
260        cx: &mut Context<Self>,
261    ) -> Result<WriteOutcome, String> {
262        let module = self
263            .modules
264            .get(mod_idx)
265            .ok_or_else(|| "módulo inválido".to_string())?;
266        let module_id = module.id.clone();
267
268        // Resolver inputs: por cada (role, field_name), parsear el
269        // value del input como Uuid.
270        let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
271        for (role, field_name) in inputs_map {
272            let raw = self
273                .form_inputs
274                .get(field_name)
275                .map(|inp| inp.read(&*cx).text().to_string())
276                .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
277            let id = Uuid::parse_str(raw.trim()).map_err(|_| {
278                format!(
279                    "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
280                )
281            })?;
282            inputs.insert(role.clone(), id);
283        }
284
285        // Resolver params: si la lista está vacía, todos los fields
286        // del form que no estén en `inputs_map` van a params.
287        let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
288        let mut params_obj = serde_json::Map::new();
289        let field_iter: Vec<String> = if params_fields.is_empty() {
290            self.form_inputs
291                .keys()
292                .filter(|k| !input_field_set.contains(*k))
293                .cloned()
294                .collect()
295        } else {
296            params_fields.to_vec()
297        };
298
299        // FieldSpec del Form view activo para parseo estricto por kind.
300        let active_form_fields: Option<Vec<FieldSpec>> =
301            self.active.as_ref().and_then(|(_, vk)| {
302                module.views.get(vk).and_then(|v| match v {
303                    View::Form(f) => Some(f.fields.clone()),
304                    _ => None,
305                })
306            });
307
308        for field_name in field_iter {
309            let raw = self
310                .form_inputs
311                .get(&field_name)
312                .map(|inp| inp.read(&*cx).text().to_string())
313                .unwrap_or_default();
314            let spec = active_form_fields
315                .as_ref()
316                .and_then(|fs| fs.iter().find(|f| f.name == field_name));
317            let value = resolve_param_value(&field_name, &raw, spec)?;
318            params_obj.insert(field_name, value);
319        }
320
321        self.backend
322            .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
323    }
324
325    /// Construye un payload desde los TextInput vivos y delega al
326    /// backend (`seed` para alta nueva, `update` con set+clear para
327    /// edit). Devuelve el `WriteOutcome` del backend (incluye
328    /// `changed` y `post_status` para el toast).
329    fn commit_seed(
330        &mut self,
331        mod_idx: usize,
332        entity: &str,
333        cx: &mut Context<Self>,
334    ) -> Result<WriteOutcome, String> {
335        let module = &self.modules[mod_idx];
336        let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
337            Some((_, view_key)) => match module.views.get(view_key) {
338                Some(View::Form(f)) => f.fields.clone(),
339                _ => return Err("la vista activa no es un Form".into()),
340            },
341            None => return Err("ninguna vista activa".into()),
342        };
343        let mut obj = serde_json::Map::new();
344        // Fields que el form deja vacíos y son optional: candidatos
345        // a Clear en el path de EDIT.
346        let mut to_clear: Vec<String> = Vec::new();
347        // EntityRef refs a validar tras parse loop (en una toma del
348        // backend en lugar de N).
349        let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
350        for f in &spec_fields {
351            let raw = self
352                .form_inputs
353                .get(&f.name)
354                .map(|input| input.read(cx).text().to_string())
355                .unwrap_or_default();
356            if f.required && raw.trim().is_empty() {
357                return Err(format!("campo '{}' es obligatorio", f.label));
358            }
359            if raw.is_empty() && !f.required {
360                to_clear.push(f.name.clone());
361                continue;
362            }
363            let value = parse_field_value(f.kind, &raw)
364                .map_err(|e| format!("campo '{}': {e}", f.label))?;
365            if f.kind == FieldKind::EntityRef {
366                if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
367                    let id = Uuid::parse_str(uuid_str)
368                        .expect("parse_field_value validated UUID");
369                    entity_refs.push((f.label.clone(), target.clone(), id));
370                }
371            }
372            obj.insert(f.name.clone(), value);
373        }
374        // Validar EntityRefs contra el backend actual. Cierre wrappea
375        // backend.load_record para mantener la firma de
376        // validate_entity_refs (que es store-agnóstica).
377        if !entity_refs.is_empty() {
378            let backend = &self.backend;
379            validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
380        }
381
382        // Ramificación: edit (hay editing para esta entity) vs alta.
383        let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
384
385        if let Some((_, id)) = editing_match {
386            // EDIT: cargar current, computar delta, llamar
387            // backend.update con set+clear pre-computados.
388            let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
389            let set_delta = compute_field_delta(&current, &obj);
390            let clear_fields = compute_clear_fields(&current, &to_clear);
391            self.backend.update(entity, id, set_delta, clear_fields)
392        } else {
393            // SEED: alta nueva — el backend genera el Uuid.
394            self.backend.seed(entity, obj)
395        }
396    }
397
398    /// Snapshot ordenado de records de una entity (proxy al backend).
399    fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
400        self.backend.list_records(entity)
401    }
402}
403
404/// Formatea el toast para la rama Action::SeedEntity según el
405/// `WriteOutcome` del backend. `was_editing` distingue "creado"
406/// vs "actualizado" — el WriteOutcome solo no alcanza porque
407/// `seed` y `update` ambos devuelven `id = Some(...)`.
408fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
409    let id_short = outcome
410        .id
411        .map(|id| short_uuid(&id))
412        .unwrap_or_default();
413    match (was_editing, outcome.changed) {
414        (false, _) => format!("creado {entity} {id_short}"),
415        (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
416        (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
417    }
418}
419
420/// Concatena el msg opcional del compact tick al toast del op
421/// original con `"; "` separator.
422fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
423    match compact_msg {
424        Some(m) => SharedString::from(format!("{base}; {m}")),
425        None => SharedString::from(base),
426    }
427}
428
429/// Walker dentro de un `Value` por path con `.` como separador.
430/// Local porque sólo lo usa la lista renderer y no tiene tests
431/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
432fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
433    let mut cur = v;
434    for seg in path.split('.') {
435        cur = cur.get(seg)?;
436    }
437    Some(cur)
438}
439
440impl<B: MetaBackend> Render for MetaApp<B> {
441    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
442        let bg = gpui::rgb(0x14171c);
443        let panel = gpui::rgb(0x1d2128);
444        let border = gpui::rgb(0x2a2f38);
445        let text = gpui::rgb(0xe6e8ec);
446        let text_dim = gpui::rgb(0x9ba1ad);
447        let accent = gpui::rgb(0x88c0d0);
448        let accent_active = gpui::rgb(0xa3be8c);
449
450        let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
451        let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
452        let confirm_banner = self.render_confirm_delete_banner(cx);
453        let toast_div = self.toast.as_ref().map(|t| {
454            div()
455                .px(px(12.))
456                .py(px(6.))
457                .bg(gpui::rgb(0x2d3a2a))
458                .text_color(gpui::rgb(0xc0e0a0))
459                .text_size(px(11.))
460                .child(t.clone())
461        });
462        let error_banner = self.load_error.as_ref().map(|e| {
463            div()
464                .px(px(12.))
465                .py(px(6.))
466                .bg(gpui::rgb(0x4a2020))
467                .text_color(gpui::rgb(0xffd0d0))
468                .text_size(px(11.))
469                .child(e.clone())
470        });
471
472        div()
473            .flex()
474            .flex_col()
475            .size_full()
476            .bg(bg)
477            // Capture phase: el Esc llega al root ANTES que cualquier
478            // TextInput descendiente. Si hay un delete pendiente, lo
479            // cancelamos. Sin pending no hacemos nada (el evento sigue
480            // su flujo normal y el TextInput recibe el Esc bubble).
481            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
482                if event.keystroke.key != "escape" {
483                    return;
484                }
485                if let Some((entity, _id)) = this.pending_delete.take() {
486                    this.toast = Some(SharedString::from(format!(
487                        "delete cancelado ({entity}) [esc]"
488                    )));
489                    cx.notify();
490                }
491            }))
492            .when_some(error_banner, |d, b| d.child(b))
493            .when_some(confirm_banner, |d, b| d.child(b))
494            .child(
495                div()
496                    .flex()
497                    .flex_row()
498                    .flex_grow()
499                    .child(sidebar)
500                    .child(main_panel),
501            )
502            .when_some(toast_div, |d, t| d.child(t))
503    }
504}
505
506impl<B: MetaBackend> MetaApp<B> {
507    /// Renderea el banner modal de confirmación cuando hay un delete
508    /// pendiente. Devuelve `None` si no hay nada que confirmar.
509    ///
510    /// UX: banner amber prominente arriba de todo el contenido. No es
511    /// un overlay flotante (GPUI no expone z-index fácilmente sin
512    /// setup) — es un row del flex_col raíz, así que el contenido
513    /// debajo se desplaza unos pixels mientras está activo. Suficiente
514    /// para forzar al usuario a leer + click.
515    fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
516        let (entity, id) = self.pending_delete.as_ref()?;
517        let entity_owned = entity.clone();
518        let id_owned = *id;
519        let entity_for_cancel = entity_owned.clone();
520        let id_short = short_uuid(&id_owned);
521        let entity_for_confirm = entity_owned.clone();
522
523        let banner_bg = gpui::rgb(0x4a3a1a);
524        let banner_text = gpui::rgb(0xf0e0a0);
525        let confirm_bg = gpui::rgb(0x6a2222);
526        let confirm_text = gpui::rgb(0xffd0d0);
527        let cancel_bg = gpui::rgb(0x2a2f38);
528        let cancel_text = gpui::rgb(0xc0c8d0);
529
530        Some(
531            div()
532                .flex()
533                .flex_row()
534                .items_center()
535                .gap(px(12.))
536                .px(px(12.))
537                .py(px(8.))
538                .bg(banner_bg)
539                .text_color(banner_text)
540                .text_size(px(12.))
541                .child(
542                    div()
543                        .flex_grow()
544                        .flex()
545                        .flex_col()
546                        .child(format!("¿Borrar {entity_owned} {id_short}?"))
547                        .child(
548                            div()
549                                .text_size(px(10.))
550                                .text_color(gpui::rgb(0xc0a070))
551                                .child("Esc para cancelar · click [Confirmar] para borrar"),
552                        ),
553                )
554                .child(
555                    div()
556                        .id(SharedString::from(format!(
557                            "confirm-del-cancel-{}",
558                            id_owned
559                        )))
560                        .px(px(10.))
561                        .py(px(4.))
562                        .bg(cancel_bg)
563                        .text_color(cancel_text)
564                        .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
565                        .child("Cancelar")
566                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
567                            this.pending_delete = None;
568                            this.toast = Some(SharedString::from(format!(
569                                "delete cancelado ({entity_for_cancel})"
570                            )));
571                            cx.notify();
572                        })),
573                )
574                .child(
575                    div()
576                        .id(SharedString::from(format!(
577                            "confirm-del-ok-{}",
578                            id_owned
579                        )))
580                        .px(px(10.))
581                        .py(px(4.))
582                        .bg(confirm_bg)
583                        .text_color(confirm_text)
584                        .hover(|d| d.bg(gpui::rgb(0x8a2828)))
585                        .child("Confirmar")
586                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
587                            // Limpiar primero para que un fallo del
588                            // commit no deje el banner colgado y el
589                            // toast tape el banner.
590                            this.pending_delete = None;
591                            match this.commit_delete(&entity_for_confirm, id_owned) {
592                                Ok(outcome) => {
593                                    let base = format!(
594                                        "borrado {entity_for_confirm} {}",
595                                        short_uuid(&id_owned)
596                                    );
597                                    this.toast = Some(append_compact_msg(
598                                        base,
599                                        outcome.post_status,
600                                    ));
601                                }
602                                Err(e) => {
603                                    this.toast = Some(SharedString::from(format!(
604                                        "error borrando: {e}"
605                                    )));
606                                }
607                            }
608                            cx.notify();
609                        })),
610                ),
611        )
612    }
613
614    fn render_sidebar(
615        &self,
616        cx: &mut Context<Self>,
617        panel: gpui::Rgba,
618        border: gpui::Rgba,
619        text: gpui::Rgba,
620        text_dim: gpui::Rgba,
621        accent_active: gpui::Rgba,
622    ) -> gpui::Div {
623        let mut sidebar = div()
624            .w(px(240.))
625            .h_full()
626            .bg(panel)
627            .border_r_1()
628            .border_color(border)
629            .flex()
630            .flex_col();
631
632        sidebar = sidebar.child(
633            div()
634                .px(px(12.))
635                .py(px(10.))
636                .text_color(text)
637                .text_size(px(13.))
638                .child("Nakui"),
639        );
640
641        if self.modules.is_empty() {
642            return sidebar.child(
643                div()
644                    .px(px(12.))
645                    .py(px(8.))
646                    .text_color(text_dim)
647                    .text_size(px(11.))
648                    .child("(no hay módulos cargados)"),
649            );
650        }
651
652        // Snapshot del active para evitar borrow del self adentro de la closure.
653        let active_snapshot = self.active.clone();
654
655        for (mod_idx, m) in self.modules.iter().enumerate() {
656            sidebar = sidebar.child(
657                div()
658                    .px(px(12.))
659                    .py(px(8.))
660                    .border_t_1()
661                    .border_color(border)
662                    .text_color(text)
663                    .text_size(px(12.))
664                    .child(m.label.clone()),
665            );
666
667            for item in &m.menu {
668                let is_active = active_snapshot
669                    .as_ref()
670                    .map(|(i, v)| *i == mod_idx && v == &item.view)
671                    .unwrap_or(false);
672                let label = item
673                    .icon
674                    .as_deref()
675                    .map(|ic| format!("{ic}  {}", item.label))
676                    .unwrap_or_else(|| item.label.clone());
677
678                let view_key = item.view.clone();
679                sidebar = sidebar.child(
680                    div()
681                        .id(SharedString::from(format!(
682                            "menu-{}-{}",
683                            mod_idx, item.view
684                        )))
685                        .px(px(20.))
686                        .py(px(6.))
687                        .text_size(px(12.))
688                        .text_color(if is_active { accent_active } else { text_dim })
689                        .when(is_active, |d| {
690                            d.bg(gpui::rgb(0x232a36)).text_color(text)
691                        })
692                        .hover(|d| d.bg(gpui::rgb(0x1f2630)))
693                        .child(label)
694                        .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
695                            this.select_view(mod_idx, view_key.clone(), cx);
696                        })),
697                );
698            }
699        }
700        sidebar
701    }
702
703    fn render_main(
704        &mut self,
705        cx: &mut Context<Self>,
706        panel: gpui::Rgba,
707        border: gpui::Rgba,
708        text: gpui::Rgba,
709        text_dim: gpui::Rgba,
710        accent: gpui::Rgba,
711    ) -> gpui::Div {
712        let main = div()
713            .flex_grow()
714            .h_full()
715            .bg(panel)
716            .flex()
717            .flex_col()
718            .p(px(16.));
719
720        let (mod_idx, view_key) = match &self.active {
721            Some(a) => (a.0, a.1.clone()),
722            None => {
723                return main.child(
724                    div()
725                        .text_color(text_dim)
726                        .child("Seleccioná un menú a la izquierda."),
727                );
728            }
729        };
730
731        let view = match self
732            .modules
733            .get(mod_idx)
734            .and_then(|m| m.views.get(&view_key))
735        {
736            Some(v) => v.clone(),
737            None => {
738                return main.child(
739                    div()
740                        .text_color(text_dim)
741                        .child(format!("Vista no encontrada: {view_key}")),
742                );
743            }
744        };
745
746        match view {
747            View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
748            View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
749        }
750    }
751
752    #[allow(clippy::too_many_arguments)]
753    fn render_list(
754        &self,
755        cx: &mut Context<Self>,
756        mut main: gpui::Div,
757        lv: &ListView,
758        mod_idx: usize,
759        border: gpui::Rgba,
760        text: gpui::Rgba,
761        text_dim: gpui::Rgba,
762        accent: gpui::Rgba,
763    ) -> gpui::Div {
764        let mut header = div()
765            .flex()
766            .flex_row()
767            .items_center()
768            .gap(px(12.))
769            .mb(px(12.))
770            .child(
771                div()
772                    .text_color(text)
773                    .text_size(px(18.))
774                    .child(lv.title.clone()),
775            );
776        for (idx, action) in lv.actions.iter().enumerate() {
777            let label = match action {
778                Action::OpenView { label, view } => {
779                    label.clone().unwrap_or_else(|| format!("→ {view}"))
780                }
781                Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
782                Action::Morphism { name, .. } => format!("⚡ {name}"),
783            };
784            let action_clone = action.clone();
785            header = header.child(
786                div()
787                    .id(SharedString::from(format!(
788                        "list-action-{mod_idx}-{idx}"
789                    )))
790                    .px(px(10.))
791                    .py(px(4.))
792                    .bg(gpui::rgb(0x232a36))
793                    .text_color(accent)
794                    .text_size(px(11.))
795                    .rounded(px(3.))
796                    .hover(|d| d.bg(gpui::rgb(0x2c3540)))
797                    .child(label)
798                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
799                        this.apply_action(action_clone.clone(), cx);
800                    })),
801            );
802        }
803        main = main.child(header);
804
805        let rows = self.list_rows(&lv.entity);
806        let total = rows.len();
807
808        let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
809        let mut col_header = div()
810            .flex()
811            .flex_row()
812            .py(px(6.))
813            .border_b_1()
814            .border_color(border)
815            .text_color(text_dim)
816            .text_size(px(11.));
817        for c in &lv.columns {
818            let frac = c.weight / total_weight;
819            col_header = col_header.child(
820                div()
821                    .flex_grow()
822                    .flex_basis(px(100. * frac))
823                    .child(c.label.clone()),
824            );
825        }
826        col_header = col_header
827            .child(div().w(px(80.)).text_color(text_dim).child("id"))
828            .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
829        main = main.child(col_header);
830
831        let entity_name = lv.entity.clone();
832        for (id, value) in &rows {
833            let id_copy = *id;
834            let entity_for_edit = entity_name.clone();
835            let entity_for_delete = entity_name.clone();
836            let mut row = div()
837                .flex()
838                .flex_row()
839                .py(px(6.))
840                .border_b_1()
841                .border_color(gpui::rgb(0x232a36))
842                .text_color(text)
843                .text_size(px(12.));
844            for c in &lv.columns {
845                let frac = c.weight / total_weight;
846                let v = lookup_field(value, &c.field);
847                row = row.child(
848                    div()
849                        .flex_grow()
850                        .flex_basis(px(100. * frac))
851                        .child(render_value(v)),
852                );
853            }
854            row = row.child(
855                div()
856                    .w(px(80.))
857                    .text_color(text_dim)
858                    .text_size(px(11.))
859                    .child(short_uuid(id)),
860            );
861            // Acciones: ✎ edit + ✕ delete por fila.
862            row = row.child(
863                div()
864                    .w(px(70.))
865                    .flex()
866                    .flex_row()
867                    .gap(px(4.))
868                    .child(
869                        div()
870                            .id(SharedString::from(format!(
871                                "row-edit-{mod_idx}-{id_copy}"
872                            )))
873                            .px(px(6.))
874                            .text_color(accent)
875                            .text_size(px(13.))
876                            .hover(|d| d.bg(gpui::rgb(0x2c3540)))
877                            .child("✎")
878                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
879                                this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
880                            })),
881                    )
882                    .child(
883                        div()
884                            .id(SharedString::from(format!(
885                                "row-del-{mod_idx}-{id_copy}"
886                            )))
887                            .px(px(6.))
888                            .text_color(gpui::rgb(0xd07070))
889                            .text_size(px(13.))
890                            .hover(|d| d.bg(gpui::rgb(0x4a2020)))
891                            .child("✕")
892                            .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
893                                // Marca para borrar en lugar de borrar
894                                // directo: el modal de confirmación se
895                                // renderea arriba en `render` y maneja
896                                // confirm/cancel.
897                                this.pending_delete =
898                                    Some((entity_for_delete.clone(), id_copy));
899                                this.toast = None;
900                                cx.notify();
901                            })),
902                    ),
903            );
904            main = main.child(row);
905        }
906
907        if rows.is_empty() {
908            main = main.child(
909                div()
910                    .py(px(12.))
911                    .text_color(text_dim)
912                    .text_size(px(12.))
913                    .child(format!("(sin {})", lv.entity)),
914            );
915        } else {
916            main = main.child(
917                div()
918                    .mt(px(8.))
919                    .text_color(text_dim)
920                    .text_size(px(11.))
921                    .child(format!("{total} fila(s)")),
922            );
923        }
924
925        main
926    }
927
928    /// Renderea el selector clickable de records existentes para un
929    /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
930    /// click en una opción setea el TextInput del field con el UUID
931    /// seleccionado. El item del UUID actualmente seleccionado (si
932    /// hay) se resalta con accent color.
933    #[allow(clippy::too_many_arguments)]
934    fn render_entity_ref_selector(
935        &self,
936        cx: &mut Context<Self>,
937        field_name: String,
938        target_entity: String,
939        text: gpui::Rgba,
940        text_dim: gpui::Rgba,
941        accent: gpui::Rgba,
942    ) -> gpui::Div {
943        let _ = text;
944        let rows = self.list_rows(&target_entity);
945        let current = self
946            .form_inputs
947            .get(&field_name)
948            .map(|inp| inp.read(&*cx).text().to_string())
949            .unwrap_or_default();
950
951        let mut container = div()
952            .mt(px(4.))
953            .pl(px(8.))
954            .border_l_2()
955            .border_color(gpui::rgb(0x2a2f38))
956            .flex()
957            .flex_col()
958            .gap(px(2.));
959
960        if rows.is_empty() {
961            return container.child(
962                div()
963                    .px(px(6.))
964                    .py(px(4.))
965                    .text_color(text_dim)
966                    .text_size(px(10.))
967                    .child(format!(
968                        "(sin {target_entity}: creá uno antes para referenciar)"
969                    )),
970            );
971        }
972
973        container = container.child(
974            div()
975                .text_color(text_dim)
976                .text_size(px(10.))
977                .child(format!("Seleccioná un {target_entity}:")),
978        );
979
980        for (id, value) in &rows {
981            let label = human_label_for_record(value, id);
982            let id_str = id.to_string();
983            let is_selected = current == id_str;
984            let field_for_click = field_name.clone();
985            let id_for_click = id_str.clone();
986            container = container.child(
987                div()
988                    .id(SharedString::from(format!(
989                        "entity-ref-{field_name}-{id_str}"
990                    )))
991                    .px(px(6.))
992                    .py(px(2.))
993                    .text_size(px(11.))
994                    .text_color(if is_selected { accent } else { text_dim })
995                    .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
996                    .hover(|d| d.bg(gpui::rgb(0x1f2630)))
997                    .child(label)
998                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
999                        if let Some(input) = this.form_inputs.get(&field_for_click) {
1000                            input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
1001                        }
1002                        cx.notify();
1003                    })),
1004            );
1005        }
1006        container
1007    }
1008
1009    #[allow(clippy::too_many_arguments)]
1010    fn render_form(
1011        &self,
1012        cx: &mut Context<Self>,
1013        mut main: gpui::Div,
1014        fv: &FormView,
1015        mod_idx: usize,
1016        _border: gpui::Rgba,
1017        text: gpui::Rgba,
1018        text_dim: gpui::Rgba,
1019        accent: gpui::Rgba,
1020    ) -> gpui::Div {
1021        // En modo edit, el título refleja eso para que el user no
1022        // se confunda creyendo que hace alta nueva.
1023        let title = match self.editing.as_ref() {
1024            Some((e, id)) if e == &fv.entity => {
1025                format!("Editar {} {}", fv.entity, short_uuid(id))
1026            }
1027            _ => fv.title.clone(),
1028        };
1029        main = main.child(
1030            div()
1031                .text_color(text)
1032                .text_size(px(18.))
1033                .mb(px(12.))
1034                .child(title),
1035        );
1036        for f in &fv.fields {
1037            let label = if f.required {
1038                format!("{} *", f.label)
1039            } else {
1040                f.label.clone()
1041            };
1042
1043            let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1044                div()
1045                    .text_color(text_dim)
1046                    .text_size(px(11.))
1047                    .mb(px(2.))
1048                    .child(label),
1049            );
1050
1051            // Mount del TextInput vivo (creado en select_view).
1052            if let Some(input) = self.form_inputs.get(&f.name) {
1053                field_box = field_box.child(input.clone());
1054            } else {
1055                // No debería pasar — select_view crea inputs por cada
1056                // field. Fallback display estático por seguridad.
1057                field_box = field_box.child(
1058                    div()
1059                        .px(px(8.))
1060                        .py(px(6.))
1061                        .bg(gpui::rgb(0x171a20))
1062                        .text_color(text_dim)
1063                        .child("(input no inicializado)"),
1064                );
1065            }
1066
1067            // Para EntityRef, agregamos un selector clickable de
1068            // records existentes debajo del TextInput. Click en una
1069            // opción setea el TextInput interno con el UUID; el
1070            // submit lee de ahí como cualquier otro field.
1071            if f.kind == FieldKind::EntityRef {
1072                if let Some(target_entity) = &f.ref_entity {
1073                    field_box = field_box.child(self.render_entity_ref_selector(
1074                        cx,
1075                        f.name.clone(),
1076                        target_entity.clone(),
1077                        text,
1078                        text_dim,
1079                        accent,
1080                    ));
1081                }
1082            }
1083
1084            if let Some(help) = &f.help {
1085                field_box = field_box.child(
1086                    div()
1087                        .mt(px(2.))
1088                        .text_color(text_dim)
1089                        .text_size(px(10.))
1090                        .child(help.clone()),
1091                );
1092            }
1093            main = main.child(field_box);
1094        }
1095
1096        let editing_this = matches!(
1097            self.editing.as_ref(),
1098            Some((e, _)) if e == &fv.entity
1099        );
1100        let submit_label = match &fv.on_submit {
1101            Action::SeedEntity { entity, .. } => {
1102                if editing_this {
1103                    format!("Guardar cambios en {entity}")
1104                } else {
1105                    format!("Crear {entity}")
1106                }
1107            }
1108            Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1109            Action::OpenView { label, view } => {
1110                label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1111            }
1112        };
1113        let submit_action = fv.on_submit.clone();
1114        main = main.child(
1115            div().mt(px(12.)).child(
1116                div()
1117                    .id(SharedString::from(format!("form-submit-{mod_idx}")))
1118                    .px(px(12.))
1119                    .py(px(6.))
1120                    .bg(gpui::rgb(0x2c3540))
1121                    .text_color(accent)
1122                    .text_size(px(12.))
1123                    .rounded(px(3.))
1124                    .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1125                    .child(submit_label)
1126                    .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1127                        this.apply_action(submit_action.clone(), cx);
1128                    })),
1129            ),
1130        );
1131
1132        main = main.child(
1133            div()
1134                .mt(px(20.))
1135                .text_color(text_dim)
1136                .text_size(px(10.))
1137                .child(
1138                    "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1139                     usá el botón. Backspace borra el último carácter.",
1140                ),
1141        );
1142        main
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
1149    //! tests de backend impl viven en el binario que provee el
1150    //! backend (ej: nakui-ui). Los helpers movidos a
1151    //! yahweh-meta-runtime se testean allí.
1152    use super::*;
1153    use serde_json::json;
1154
1155    #[test]
1156    fn lookup_field_simple_and_nested() {
1157        let v = json!({
1158            "name": "Acme",
1159            "address": { "city": "Bogotá", "country": "CO" }
1160        });
1161        assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1162        assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1163        assert!(lookup_field(&v, "missing").is_none());
1164        assert!(lookup_field(&v, "address.zipcode").is_none());
1165    }
1166
1167    #[test]
1168    fn append_compact_msg_handles_both_branches() {
1169        // Sin compact: base solo, sin separador.
1170        let s = append_compact_msg("creado X".into(), None);
1171        assert_eq!(s.as_ref(), "creado X");
1172        // Con compact: concatena con "; ".
1173        let s = append_compact_msg(
1174            "creado X".into(),
1175            Some("auto-compact: snapshot @ seq 49".into()),
1176        );
1177        assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
1178    }
1179
1180    #[test]
1181    fn format_seed_toast_distinguishes_create_update_no_change() {
1182        let id = Uuid::new_v4();
1183        let outcome_create = WriteOutcome {
1184            id: Some(id),
1185            changed: 1,
1186            post_status: None,
1187        };
1188        let toast = format_seed_toast("Customer", false, &outcome_create);
1189        assert!(toast.starts_with("creado Customer"));
1190
1191        let outcome_update = WriteOutcome {
1192            id: Some(id),
1193            changed: 3,
1194            post_status: None,
1195        };
1196        let toast = format_seed_toast("Customer", true, &outcome_update);
1197        assert!(toast.starts_with("actualizado Customer"));
1198        assert!(toast.contains("(3 campo(s))"));
1199
1200        let outcome_no_change = WriteOutcome::no_change(id);
1201        let toast = format_seed_toast("Customer", true, &outcome_no_change);
1202        assert!(toast.contains("sin cambios"));
1203    }
1204}