refactor(yahweh): Fase 2c — widget render extraído a yahweh-widget-meta-form
Cierra el refactor de UI. El widget render (forms, lists, modal de
delete, EntityRef selector, sidebar, key handlers) deja de vivir en
nakui-ui y pasa a un crate yahweh nuevo, genérico sobre MetaBackend.
crates/modules/ui_engine/widgets/meta-form/ (yahweh-widget-meta-form):
- pub MetaApp<B: MetaBackend> con todo el state UI + impl Render
+ handlers + render_*. El bound `B: MetaBackend` se propaga.
- pub fn new(modules, backend, initial_toast, initial_error, cx):
constructor sin bootstrap. Caller pre-construye todo.
- Helpers locales del widget: lookup_field, append_compact_msg,
format_seed_toast.
- Cero deps a nakui o brahman-cards. Reusable por cualquier app.
- 3 tests funcionales puros (lookup_field, append_compact_msg,
format_seed_toast).
nakui-ui (shell):
- main.rs: 1959 → 424 líneas (78% reducción). Sólo bootstrap:
load_ui_modules + load executors + NakuiBackend::open +
cx.open_window con MetaApp::<NakuiBackend>::new como root view.
- Dep nueva yahweh-widget-meta-form.
- Tests E2E quedan: event_log_replay, morphism_pipeline real
sales, load_ui_modules x3 (4 shell). NakuiBackend tests siguen
en backend.rs (8). Widget tests en su crate.
Distribución total tests refactor yahweh:
- yahweh-meta-schema: 8
- yahweh-meta-runtime: 42
- yahweh-widget-meta-form: 3
- brahman-cards: 26
- nakui-ui: 12 (4 shell + 8 backend)
Total: 91 tests cubriendo el área.
Cada crate compila individualmente. Fase 3 (shell mínimo) era
implícita en F2c — el shell ya quedó en 424 líneas.
Pendientes restantes: KCL → Nickel, eliminar card.k.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b3a99f38dcbcd3819b2358b3c54732f61baaf7a4 · ruwaq Sergio · 2026-05-10T02:00:34Z (5 killa ñawpaq) · tayta 2462aca44 · kay commitpi sach'ata qhaway
Semántico sayk'uy · 55
crates/apps/nakui-ui/src/main.rs
crates/modules/ui_engine/widgets/meta-form/src/lib.rs
7 qillqakuna · +1461 −1678
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 694724d..5a6b332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,89 @@ ratio/diff ver `git show <sha>`.
## 2026-05-10
+### refactor(yahweh): Fase 2c — extracción del widget al crate `yahweh-widget-meta-form`
+Cierra el refactor de UI: el widget render (forms, lists, modal de
+delete, EntityRef selector, sidebar, key handlers) deja de vivir en
+el binario nakui-ui y pasa a un crate yahweh nuevo, genérico sobre
+`MetaBackend`. nakui-ui queda como un shell de bootstrap de 424
+líneas.
+
+Crate nuevo: `crates/modules/ui_engine/widgets/meta-form/`
+(`yahweh-widget-meta-form`):
+- **Deps**: gpui, yahweh-meta-schema, yahweh-meta-runtime, yahweh-theme,
+ yahweh-widget-text-input, serde_json, uuid. **Cero deps a nakui** o
+ brahman-cards — reusable por cualquier app.
+- **`MetaApp<B: MetaBackend>`** público: estructura genérica con
+ `modules`, `backend: B`, `active`, `form_inputs`, `editing`,
+ `pending_delete`, `toast`, `load_error`. El bound `B: MetaBackend`
+ se propaga a todos los `impl MetaApp<B>` y al `impl Render for
+ MetaApp<B>`.
+- **`MetaApp::new(modules, backend, initial_toast, initial_error, cx)`**:
+ constructor sin lógica de bootstrap. El caller pre-construye
+ modules + backend + cualquier mensaje inicial. La active view
+ default es la primera entry del menú del primer módulo.
+- **Methods preservados** del original (rename simbólico): select_view,
+ open_edit, commit_seed, commit_morphism, commit_delete, apply_action,
+ list_rows, render_*, tick interno via WriteOutcome.post_status.
+- **Helpers locales del widget**: `lookup_field` (path walker JSON
+ por la lista renderer), `append_compact_msg` (concatenador del
+ toast), `format_seed_toast` (decide "creado/actualizado/sin cambios"
+ según `WriteOutcome`).
+- **3 tests funcionales puros**: `lookup_field`, `append_compact_msg`,
+ `format_seed_toast`. Tests con GPUI cx no son posibles sin un
+ TestAppContext setup; quedan implícitos vía type-check del trait
+ bound.
+
+Cambios en `nakui-ui` (shell):
+- **main.rs**: 1959 → **424** líneas (78% reducción). Ahora sólo:
+ 1. Carga modules via `brahman_cards::load_cards_from_dir` +
+ `load_ui_modules` (filtra UiModule body, valida, dedup).
+ 2. Carga executors para módulos con `nakui_module_dir`.
+ 3. `NakuiBackend::open(...)` para inicializar el backend.
+ 4. `cx.open_window(...)` con `MetaApp::<NakuiBackend>::new(...)`
+ como root view.
+- **`use yahweh_widget_meta_form::MetaApp`** + dep nueva en
+ Cargo.toml. Los imports de yahweh-meta-runtime/schema desaparecen
+ de main (los consume el widget internamente).
+- **Tests del shell**: 4 tests E2E que tocan nakui-core directamente
+ (event_log_replay, morphism_pipeline_real_sales_vender,
+ load_ui_modules x3). Los tests del NakuiBackend impl quedan en
+ `backend.rs` (8 tests). Los tests del widget viven en su propio
+ crate.
+- **`backend.rs`**: sin cambios (NakuiBackend ya estaba aislado en
+ Fase 2b).
+
+Distribución final del refactor yahweh:
+- `yahweh-meta-schema`: 8 tests (data puro).
+- `yahweh-meta-runtime`: 42 tests (helpers + trait MetaBackend).
+- `yahweh-widget-meta-form`: 3 tests (widget genérico).
+- `brahman-cards`: 26 tests (loader unificado).
+- `nakui-ui`: 12 tests (4 shell + 8 backend impl).
+- **Total: 91 tests** cubriendo el área.
+
+Cada crate compila individualmente. El widget consume el trait sin
+saber qué backend hay debajo; `nakui-ui` provee el trait wireado a
+nakui-core; cualquier futuro shell (mock para tests, otro stack de
+storage) puede reusar el widget sin cambio.
+
+Lo que NO hace Fase 2c:
+- No mueve `format_seed_toast`/`append_compact_msg`/`lookup_field`
+ a `yahweh-meta-runtime`. Son lo bastante widget-flavored
+ (`SharedString` de gpui, decisiones de UX del toast, etc.) que
+ preferí dejarlos al lado del render.
+- No introduce un `MetaApp::with_status` builder pattern. La
+ signature de `new` con 5 args es manejable; si crece, se refactor
+ después.
+- No expone configuración del widget (theme override, layout
+ custom, etc.). Cuando emerja una segunda app que use el widget
+ con preferencias distintas, se agregan opts.
+
+**Pendientes**:
+1. **KCL → Nickel**: kcl_wrapper en nakui-core reemplazado por
+ evaluación de Nickel contracts. Migrar los 3 schemas .k de
+ sales/inventory/treasury a .ncl.
+2. **`card.k` eliminado** (REFERENCE ONLY documentado en su header).
+
### refactor(yahweh): Fase 2b — `MetaBackend` trait + `NakuiBackend` + MetaUi consume el backend
Materialización del trait que diseñamos en charla. Tres pasos
combinados en un solo commit:
diff --git a/Cargo.lock b/Cargo.lock
index 4caecf6..44ef1e5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6402,6 +6402,7 @@ dependencies = [
"yahweh-meta-runtime",
"yahweh-meta-schema",
"yahweh-theme",
+ "yahweh-widget-meta-form",
"yahweh-widget-text-input",
]
@@ -12926,6 +12927,19 @@ dependencies = [
"yahweh-core",
]
+[[package]]
+name = "yahweh-widget-meta-form"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "serde_json",
+ "uuid",
+ "yahweh-meta-runtime",
+ "yahweh-meta-schema",
+ "yahweh-theme",
+ "yahweh-widget-text-input",
+]
+
[[package]]
name = "yahweh-widget-splitter"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index cd9e1cc..6c1a7ce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,6 +62,7 @@ members = [
"crates/modules/ui_engine/widgets/tabs",
"crates/modules/ui_engine/widgets/tiled",
"crates/modules/ui_engine/widgets/text_input",
+ "crates/modules/ui_engine/widgets/meta-form",
# ============================================================
# modules/nakui/ — ERP matemático (nakui absorbido)
diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml
index 9fe6b12..2305be1 100644
--- a/crates/apps/nakui-ui/Cargo.toml
+++ b/crates/apps/nakui-ui/Cargo.toml
@@ -11,6 +11,7 @@ yahweh-meta-schema = { path = "../../modules/ui_engine/libs/meta-schema" }
yahweh-meta-runtime = { path = "../../modules/ui_engine/libs/meta-runtime" }
brahman-cards = { path = "../../core/brahman-cards" }
yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" }
+yahweh-widget-meta-form = { path = "../../modules/ui_engine/widgets/meta-form" }
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
gpui = { workspace = true }
serde_json = { workspace = true }
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 12c6c15..00da256 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -1,19 +1,15 @@
-//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
+//! `nakui-ui` — binario shell de la metainterfaz Nakui.
//!
-//! Carga módulos desde un directorio (cada módulo = un
-//! `module.json`), monta sidebar con sus menús, y renderea la vista
-//! activa en el panel principal:
-//!
-//! - **List**: tabla de instancias del entity. Botones de acción en
-//! el header (típicamente "Nuevo" → form).
-//! - **Form**: campos editables (con `yahweh-widget-text-input` para
-//! teclado real); al submit, escribe al `MemoryStore` in-process
-//! via `seed_entity` (alta directa) o por morphism (TODO en este
-//! iter).
-//!
-//! Todo el storage es in-memory por ahora — el escenario "save to
-//! disk" se materializa cuando el daemon Nakui exista. La
-//! arquitectura permite swap sin tocar la UI.
+//! Compone:
+//! - **Yahweh widget** [`yahweh_widget_meta_form::MetaApp`] genérico
+//! sobre cualquier `MetaBackend` — toda la lógica de
+//! render/edit/delete/morphism vive ahí.
+//! - **Backend** [`backend::NakuiBackend`] — implementa el trait
+//! wireado al stack nakui-core (event log + MemoryStore + Rhai
+//! executors).
+//! - **Loader** [`load_ui_modules`] — usa `brahman_cards` para leer
+//! `card.{ncl,json}` / `module.{ncl,json}` desde
+//! `NAKUI_MODULES_DIR`, filtra a UiModule body, valida.
//!
//! ## Uso
//!
@@ -29,24 +25,15 @@ use std::path::PathBuf;
use std::sync::Arc;
use gpui::{
- div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
- KeyDownEvent, Render, SharedString, Window, WindowBounds, WindowOptions,
+ prelude::*, px, App, Application, Bounds, SharedString, TitlebarOptions, WindowBounds,
+ WindowOptions,
};
use brahman_cards::CardBody;
use nakui_core::executor::Executor;
-use yahweh_meta_runtime::{
- compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
- render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
- MetaBackend, WriteOutcome,
-};
-use yahweh_meta_schema::{
- Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
-};
-use serde_json::Value;
-use uuid::Uuid;
+use yahweh_meta_schema::Module;
use yahweh_theme::Theme;
-use yahweh_widget_text_input::TextInput;
+use yahweh_widget_meta_form::MetaApp;
use crate::backend::NakuiBackend;
@@ -56,99 +43,36 @@ fn main() {
// crear el window evita que panicee.
Theme::install_default(cx);
- let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
- cx.open_window(
- WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(bounds)),
- titlebar: Some(gpui::TitlebarOptions {
- title: Some(SharedString::from("Nakui")),
- ..Default::default()
- }),
- ..Default::default()
- },
- |_w, cx| cx.new(MetaUi::new),
- )
- .expect("open window");
- cx.activate(true);
- });
-}
-
-/// Estado del runtime de UI. Toda la persistencia/ejecución está
-/// detrás del trait `MetaBackend`; este struct sólo conoce GPUI
-/// state y el schema de los módulos.
-struct MetaUi {
- /// Módulos cargados, ordenados por id.
- modules: Vec<Module>,
- /// Backend que ejecuta seed/update/delete/morphism. Para Nakui
- /// esto wirea al stack de event_log + MemoryStore + Executors.
- /// Otra app podría implementar `MetaBackend` distinto.
- backend: NakuiBackend,
- /// (módulo idx, vista key) actualmente activos.
- active: Option<(usize, String)>,
- /// Inputs vivos para el form actual: nombre del campo → TextInput.
- /// Se reemplaza al cambiar de vista (drop de los anteriores).
- form_inputs: BTreeMap<String, Entity<TextInput>>,
- /// Si está set, el próximo render del Form pre-llena los inputs
- /// con los valores del record indicado, y `commit_seed` emite
- /// un `update` (no un seed nuevo). Limpia al cambiar de view o
- /// tras submit exitoso.
- editing: Option<(String, Uuid)>,
- /// Si está set, el banner modal de confirmación de delete está
- /// activo: `(entity, id)` del record que el usuario marcó para
- /// borrar. Permanece hasta que el usuario click [Confirmar]
- /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
- /// Navegación a otra view también cancela.
- pending_delete: Option<(String, Uuid)>,
- /// Mensaje toast al pie (success de submit, error de carga, etc.).
- toast: Option<SharedString>,
- /// Si la carga de módulos falló al inicio.
- load_error: Option<SharedString>,
-}
-
-impl MetaUi {
- fn new(_cx: &mut Context<Self>) -> Self {
+ // 1. Cargar módulos (Cards UiModule via brahman_cards).
let modules_dir = std::env::var("NAKUI_MODULES_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("nakui-modules"));
-
- // Carga via el brazo unificado: brahman_cards::load_cards_from_dir
- // walkea subdirs buscando card.ncl/card.json/module.ncl/module.json,
- // dispatcha al reader apropiado, devuelve Vec<Card>. Acá filtramos
- // a los UiModule body variants y aplicamos las validaciones
- // específicas de la UI (validate de cada Module + dedup de id).
- // Cards de otros kinds (Ente, Monad) que aparezcan en el dir se
- // skipean con un msg al banner — no son fatales pero el usuario
- // sabe que estaban ahí.
let (modules, mut load_error) = match load_ui_modules(&modules_dir) {
Ok((mods, skipped)) => {
let toast = if skipped.is_empty() {
None
} else {
- Some(SharedString::from(format!(
+ Some(format!(
"skipeé {} card(s) no-UiModule en {}: {:?}",
skipped.len(),
modules_dir.display(),
skipped
- )))
+ ))
};
(mods, toast)
}
Err(e) => (
Vec::new(),
- Some(SharedString::from(format!(
+ Some(format!(
"no pude cargar módulos de {}: {e}",
modules_dir.display()
- ))),
+ )),
),
};
- // Cargar Executors para los módulos que declararon
- // `nakui_module_dir`. Resolvemos paths relativos al
- // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
- // pwd. Cualquier error de carga deja la entry afuera y
- // anota al banner — el morphism queda inejecutable para ese
- // módulo pero el resto sigue funcionando.
+ // 2. Cargar Executors para módulos con `nakui_module_dir`.
+ // Path resuelve relativo al subdir del módulo.
let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
for m in &modules {
let Some(rel) = &m.nakui_module_dir else {
@@ -170,20 +94,15 @@ impl MetaUi {
m.id,
nakui_dir.display()
);
- match &load_error {
- Some(prev) => {
- load_error = Some(SharedString::from(format!("{prev}; {msg}")));
- }
- None => load_error = Some(SharedString::from(msg)),
- }
+ load_error = Some(match load_error {
+ Some(prev) => format!("{prev}; {msg}"),
+ None => msg,
+ });
}
}
}
- // Persistencia: el backend abre el log + snapshot + replay.
- // Path del log por env `NAKUI_EVENT_LOG` (default
- // `./nakui-ui-state.jsonl`). Threshold de auto-compaction
- // via env `NAKUI_SNAPSHOT_THRESHOLD` (default 50).
+ // 3. Construir el backend Nakui (abre log, replay, compact).
let log_path = std::env::var("NAKUI_EVENT_LOG")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
@@ -193,346 +112,34 @@ impl MetaUi {
.unwrap_or(50);
let (backend, status) =
NakuiBackend::open(log_path, snapshot_threshold, executors);
- let initial_toast = status.init_toast.map(SharedString::from);
+ let initial_toast = status.init_toast;
if let Some(msg) = status.load_error {
load_error = Some(match load_error {
- Some(prev) => SharedString::from(format!("{prev}; {msg}")),
- None => SharedString::from(msg),
+ Some(prev) => format!("{prev}; {msg}"),
+ None => msg,
});
}
- let active = modules
- .first()
- .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
-
- Self {
- modules,
- backend,
- active,
- form_inputs: BTreeMap::new(),
- editing: None,
- pending_delete: None,
- toast: initial_toast,
- load_error,
- }
- }
-
- /// Cambia la vista activa. Si la nueva vista es un Form, crea
- /// `TextInput` entities para cada field. Pre-llena con valores
- /// del record si hay `editing` para esa entity; si no, usa el
- /// `default` del schema.
- /// Drop de los inputs anteriores ocurre al sobreescribir el map.
- fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
- self.active = Some((mod_idx, view_key.clone()));
- self.toast = None;
- // Navegar a otra view cancela cualquier delete pendiente:
- // el record marcado puede no estar visible en la nueva view.
- self.pending_delete = None;
- self.form_inputs = BTreeMap::new();
- if let Some(module) = self.modules.get(mod_idx) {
- if let Some(View::Form(form)) = module.views.get(&view_key) {
- // Snapshot del record si estamos editando esta entity.
- let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
- if e == &form.entity {
- self.backend.load_record(e, *id)
- } else {
- None
- }
- });
- for f in &form.fields {
- let initial = if let Some(rec) = &editing_record {
- rec.get(&f.name)
- .map(value_to_input_text)
- .unwrap_or_else(|| f.default.clone().unwrap_or_default())
- } else {
- f.default.clone().unwrap_or_default()
- };
- let input = cx.new(|cx| TextInput::new(initial, cx));
- self.form_inputs.insert(f.name.clone(), input);
- }
- } else {
- // Cambiar a una view que no es Form invalida el editing
- // pendiente.
- self.editing = None;
- }
- }
- cx.notify();
- }
-
- /// Inicia un edit del record: setea `editing` y abre la primera
- /// view de tipo Form del módulo (convención: la del schema).
- fn open_edit(
- &mut self,
- mod_idx: usize,
- entity: String,
- id: Uuid,
- cx: &mut Context<Self>,
- ) {
- self.editing = Some((entity.clone(), id));
- let form_view_key = self.modules.get(mod_idx).and_then(|m| {
- m.views
- .iter()
- .find_map(|(key, v)| match v {
- View::Form(form) if form.entity == entity => Some(key.clone()),
- _ => None,
- })
- });
- match form_view_key {
- Some(key) => self.select_view(mod_idx, key, cx),
- None => {
- self.toast = Some(SharedString::from(format!(
- "no hay form view para entity '{entity}' en este módulo"
- )));
- self.editing = None;
- cx.notify();
- }
- }
- }
-
- /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
- /// aplica al store via `apply` (no via remove directo, mantiene
- /// el modelo de "todo cambio post-seed pasa por ops").
- /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
- /// del backend (incluye eventual `post_status` del compact tick).
- fn commit_delete(
- &mut self,
- entity: &str,
- id: Uuid,
- ) -> Result<WriteOutcome, String> {
- self.backend.delete(entity, id)
- }
-
- /// Aplica una acción (click en menú, botón de form, action de
- /// list). Mutaciones contra el backend ocurren acá.
- fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
- let mod_idx = match self.active.as_ref() {
- Some((i, _)) => *i,
- None => return,
- };
- match action {
- Action::OpenView { view, .. } => {
- // Salir a otra view cancela el edit pendiente.
- self.editing = None;
- self.select_view(mod_idx, view, cx);
- }
- Action::SeedEntity { entity, next_view } => {
- let was_editing = self.editing.is_some();
- match self.commit_seed(mod_idx, &entity, cx) {
- Ok(outcome) => {
- let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
- self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
- // Limpia editing tras un commit exitoso —
- // el record ya está sincronizado (incluso
- // un NoChange cierra el modo edit).
- self.editing = None;
- if let Some(v) = next_view {
- self.select_view(mod_idx, v, cx);
- } else {
- for input in self.form_inputs.values() {
- input.update(cx, |inp, cx| inp.set_text("", cx));
- }
- }
- }
- Err(e) => {
- self.toast = Some(SharedString::from(format!("error: {e}")));
- }
- }
- cx.notify();
- }
- Action::Morphism {
- name,
- inputs,
- params,
- next_view,
- } => {
- match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
- Ok(outcome) => {
- let base = format!(
- "morphism '{name}' OK ({} op(s) aplicadas)",
- outcome.changed
- );
- self.toast = Some(append_compact_msg(base, outcome.post_status));
- if let Some(v) = next_view {
- self.select_view(mod_idx, v, cx);
- }
- }
- Err(e) => {
- self.toast = Some(SharedString::from(format!(
- "morphism '{name}' falló: {e}"
- )));
- }
- }
- cx.notify();
- }
- }
- }
-
- /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
- /// + params (Value object) leyendo los TextInput del form.
- fn commit_morphism(
- &mut self,
- mod_idx: usize,
- morphism: &str,
- inputs_map: &BTreeMap<String, String>,
- params_fields: &[String],
- cx: &mut Context<Self>,
- ) -> Result<WriteOutcome, String> {
- let module = self
- .modules
- .get(mod_idx)
- .ok_or_else(|| "módulo inválido".to_string())?;
- let module_id = module.id.clone();
-
- // Resolver inputs: por cada (role, field_name), parsear el
- // value del input como Uuid.
- let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
- for (role, field_name) in inputs_map {
- let raw = self
- .form_inputs
- .get(field_name)
- .map(|inp| inp.read(&*cx).text().to_string())
- .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
- let id = Uuid::parse_str(raw.trim()).map_err(|_| {
- format!(
- "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
- )
- })?;
- inputs.insert(role.clone(), id);
- }
-
- // Resolver params: si la lista está vacía, todos los fields
- // del form que no estén en `inputs_map` van a params.
- let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
- let mut params_obj = serde_json::Map::new();
- let field_iter: Vec<String> = if params_fields.is_empty() {
- self.form_inputs
- .keys()
- .filter(|k| !input_field_set.contains(*k))
- .cloned()
- .collect()
- } else {
- params_fields.to_vec()
- };
-
- // FieldSpec del Form view activo para parseo estricto por kind.
- let active_form_fields: Option<Vec<FieldSpec>> =
- self.active.as_ref().and_then(|(_, vk)| {
- module.views.get(vk).and_then(|v| match v {
- View::Form(f) => Some(f.fields.clone()),
- _ => None,
+ // 4. Abrir window con MetaApp<NakuiBackend> como root view.
+ let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ titlebar: Some(TitlebarOptions {
+ title: Some(SharedString::from("Nakui")),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ |_w, cx| {
+ cx.new(|cx| {
+ MetaApp::new(modules, backend, initial_toast, load_error, cx)
})
- });
-
- for field_name in field_iter {
- let raw = self
- .form_inputs
- .get(&field_name)
- .map(|inp| inp.read(&*cx).text().to_string())
- .unwrap_or_default();
- let spec = active_form_fields
- .as_ref()
- .and_then(|fs| fs.iter().find(|f| f.name == field_name));
- let value = resolve_param_value(&field_name, &raw, spec)?;
- params_obj.insert(field_name, value);
- }
-
- self.backend
- .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
- }
-
- /// Construye un payload desde los TextInput vivos y delega al
- /// backend (`seed` para alta nueva, `update` con set+clear para
- /// edit). Devuelve el `WriteOutcome` del backend (incluye
- /// `changed` y `post_status` para el toast).
- fn commit_seed(
- &mut self,
- mod_idx: usize,
- entity: &str,
- cx: &mut Context<Self>,
- ) -> Result<WriteOutcome, String> {
- let module = &self.modules[mod_idx];
- let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
- Some((_, view_key)) => match module.views.get(view_key) {
- Some(View::Form(f)) => f.fields.clone(),
- _ => return Err("la vista activa no es un Form".into()),
},
- None => return Err("ninguna vista activa".into()),
- };
- let mut obj = serde_json::Map::new();
- // Fields que el form deja vacíos y son optional: candidatos
- // a Clear en el path de EDIT.
- let mut to_clear: Vec<String> = Vec::new();
- // EntityRef refs a validar tras parse loop (en una toma del
- // backend en lugar de N).
- let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
- for f in &spec_fields {
- let raw = self
- .form_inputs
- .get(&f.name)
- .map(|input| input.read(cx).text().to_string())
- .unwrap_or_default();
- if f.required && raw.trim().is_empty() {
- return Err(format!("campo '{}' es obligatorio", f.label));
- }
- if raw.is_empty() && !f.required {
- to_clear.push(f.name.clone());
- continue;
- }
- let value = parse_field_value(f.kind, &raw)
- .map_err(|e| format!("campo '{}': {e}", f.label))?;
- if f.kind == FieldKind::EntityRef {
- if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
- let id = Uuid::parse_str(uuid_str)
- .expect("parse_field_value validated UUID");
- entity_refs.push((f.label.clone(), target.clone(), id));
- }
- }
- obj.insert(f.name.clone(), value);
- }
- // Validar EntityRefs contra el backend actual. Cierre wrappea
- // backend.load_record para mantener la firma de
- // validate_entity_refs (que es store-agnóstica).
- if !entity_refs.is_empty() {
- let backend = &self.backend;
- validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
- }
-
- // Ramificación: edit (hay editing para esta entity) vs alta.
- let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
-
- if let Some((_, id)) = editing_match {
- // EDIT: cargar current, computar delta, llamar
- // backend.update con set+clear pre-computados.
- let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
- let set_delta = compute_field_delta(¤t, &obj);
- let clear_fields = compute_clear_fields(¤t, &to_clear);
- self.backend.update(entity, id, set_delta, clear_fields)
- } else {
- // SEED: alta nueva — el backend genera el Uuid.
- self.backend.seed(entity, obj)
- }
- }
-
- /// Snapshot ordenado de records de una entity (proxy al backend).
- fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
- self.backend.list_records(entity)
- }
-}
-
-/// Formatea el toast para la rama Action::SeedEntity según el
-/// `WriteOutcome` del backend. `was_editing` distingue "creado"
-/// vs "actualizado" — el WriteOutcome solo no alcanza porque
-/// `seed` y `update` ambos devuelven `id = Some(...)`.
-fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
- let id_short = outcome
- .id
- .map(|id| short_uuid(&id))
- .unwrap_or_default();
- match (was_editing, outcome.changed) {
- (false, _) => format!("creado {entity} {id_short}"),
- (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
- (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
- }
+ )
+ .expect("open window");
+ cx.activate(true);
+ });
}
/// Carga UiModules desde un directorio via el brazo unificado
@@ -579,1127 +186,30 @@ fn load_ui_modules(
Ok((modules, skipped))
}
-fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
- match compact_msg {
- Some(m) => SharedString::from(format!("{base}; {m}")),
- None => SharedString::from(base),
- }
-}
-
-/// Walker dentro de un `Value` por path con `.` como separador.
-/// Local porque sólo lo usa la lista renderer y no tiene tests
-/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
-fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
- let mut cur = v;
- for seg in path.split('.') {
- cur = cur.get(seg)?;
- }
- Some(cur)
-}
-
-impl Render for MetaUi {
- fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let bg = gpui::rgb(0x14171c);
- let panel = gpui::rgb(0x1d2128);
- let border = gpui::rgb(0x2a2f38);
- let text = gpui::rgb(0xe6e8ec);
- let text_dim = gpui::rgb(0x9ba1ad);
- let accent = gpui::rgb(0x88c0d0);
- let accent_active = gpui::rgb(0xa3be8c);
-
- let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
- let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
- let confirm_banner = self.render_confirm_delete_banner(cx);
- let toast_div = self.toast.as_ref().map(|t| {
- div()
- .px(px(12.))
- .py(px(6.))
- .bg(gpui::rgb(0x2d3a2a))
- .text_color(gpui::rgb(0xc0e0a0))
- .text_size(px(11.))
- .child(t.clone())
- });
- let error_banner = self.load_error.as_ref().map(|e| {
- div()
- .px(px(12.))
- .py(px(6.))
- .bg(gpui::rgb(0x4a2020))
- .text_color(gpui::rgb(0xffd0d0))
- .text_size(px(11.))
- .child(e.clone())
- });
-
- div()
- .flex()
- .flex_col()
- .size_full()
- .bg(bg)
- // Capture phase: el Esc llega al root ANTES que cualquier
- // TextInput descendiente. Si hay un delete pendiente, lo
- // cancelamos. Sin pending no hacemos nada (el evento sigue
- // su flujo normal y el TextInput recibe el Esc bubble).
- .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
- if event.keystroke.key != "escape" {
- return;
- }
- if let Some((entity, _id)) = this.pending_delete.take() {
- this.toast = Some(SharedString::from(format!(
- "delete cancelado ({entity}) [esc]"
- )));
- cx.notify();
- }
- }))
- .when_some(error_banner, |d, b| d.child(b))
- .when_some(confirm_banner, |d, b| d.child(b))
- .child(
- div()
- .flex()
- .flex_row()
- .flex_grow()
- .child(sidebar)
- .child(main_panel),
- )
- .when_some(toast_div, |d, t| d.child(t))
- }
-}
-
-impl MetaUi {
- /// Renderea el banner modal de confirmación cuando hay un delete
- /// pendiente. Devuelve `None` si no hay nada que confirmar.
- ///
- /// UX: banner amber prominente arriba de todo el contenido. No es
- /// un overlay flotante (GPUI no expone z-index fácilmente sin
- /// setup) — es un row del flex_col raíz, así que el contenido
- /// debajo se desplaza unos pixels mientras está activo. Suficiente
- /// para forzar al usuario a leer + click.
- fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
- let (entity, id) = self.pending_delete.as_ref()?;
- let entity_owned = entity.clone();
- let id_owned = *id;
- let entity_for_cancel = entity_owned.clone();
- let id_short = short_uuid(&id_owned);
- let entity_for_confirm = entity_owned.clone();
-
- let banner_bg = gpui::rgb(0x4a3a1a);
- let banner_text = gpui::rgb(0xf0e0a0);
- let confirm_bg = gpui::rgb(0x6a2222);
- let confirm_text = gpui::rgb(0xffd0d0);
- let cancel_bg = gpui::rgb(0x2a2f38);
- let cancel_text = gpui::rgb(0xc0c8d0);
-
- Some(
- div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(12.))
- .px(px(12.))
- .py(px(8.))
- .bg(banner_bg)
- .text_color(banner_text)
- .text_size(px(12.))
- .child(
- div()
- .flex_grow()
- .flex()
- .flex_col()
- .child(format!("¿Borrar {entity_owned} {id_short}?"))
- .child(
- div()
- .text_size(px(10.))
- .text_color(gpui::rgb(0xc0a070))
- .child("Esc para cancelar · click [Confirmar] para borrar"),
- ),
- )
- .child(
- div()
- .id(SharedString::from(format!(
- "confirm-del-cancel-{}",
- id_owned
- )))
- .px(px(10.))
- .py(px(4.))
- .bg(cancel_bg)
- .text_color(cancel_text)
- .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
- .child("Cancelar")
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- this.pending_delete = None;
- this.toast = Some(SharedString::from(format!(
- "delete cancelado ({entity_for_cancel})"
- )));
- cx.notify();
- })),
- )
- .child(
- div()
- .id(SharedString::from(format!(
- "confirm-del-ok-{}",
- id_owned
- )))
- .px(px(10.))
- .py(px(4.))
- .bg(confirm_bg)
- .text_color(confirm_text)
- .hover(|d| d.bg(gpui::rgb(0x8a2828)))
- .child("Confirmar")
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- // Limpiar primero para que un fallo del
- // commit no deje el banner colgado y el
- // toast tape el banner.
- this.pending_delete = None;
- match this.commit_delete(&entity_for_confirm, id_owned) {
- Ok(outcome) => {
- let base = format!(
- "borrado {entity_for_confirm} {}",
- short_uuid(&id_owned)
- );
- this.toast = Some(append_compact_msg(
- base,
- outcome.post_status,
- ));
- }
- Err(e) => {
- this.toast = Some(SharedString::from(format!(
- "error borrando: {e}"
- )));
- }
- }
- cx.notify();
- })),
- ),
- )
- }
-
- fn render_sidebar(
- &self,
- cx: &mut Context<Self>,
- panel: gpui::Rgba,
- border: gpui::Rgba,
- text: gpui::Rgba,
- text_dim: gpui::Rgba,
- accent_active: gpui::Rgba,
- ) -> gpui::Div {
- let mut sidebar = div()
- .w(px(240.))
- .h_full()
- .bg(panel)
- .border_r_1()
- .border_color(border)
- .flex()
- .flex_col();
-
- sidebar = sidebar.child(
- div()
- .px(px(12.))
- .py(px(10.))
- .text_color(text)
- .text_size(px(13.))
- .child("Nakui"),
- );
-
- if self.modules.is_empty() {
- return sidebar.child(
- div()
- .px(px(12.))
- .py(px(8.))
- .text_color(text_dim)
- .text_size(px(11.))
- .child("(no hay módulos cargados)"),
- );
- }
-
- // Snapshot del active para evitar borrow del self adentro de la closure.
- let active_snapshot = self.active.clone();
-
- for (mod_idx, m) in self.modules.iter().enumerate() {
- sidebar = sidebar.child(
- div()
- .px(px(12.))
- .py(px(8.))
- .border_t_1()
- .border_color(border)
- .text_color(text)
- .text_size(px(12.))
- .child(m.label.clone()),
- );
-
- for item in &m.menu {
- let is_active = active_snapshot
- .as_ref()
- .map(|(i, v)| *i == mod_idx && v == &item.view)
- .unwrap_or(false);
- let label = item
- .icon
- .as_deref()
- .map(|ic| format!("{ic} {}", item.label))
- .unwrap_or_else(|| item.label.clone());
-
- let view_key = item.view.clone();
- sidebar = sidebar.child(
- div()
- .id(SharedString::from(format!(
- "menu-{}-{}",
- mod_idx, item.view
- )))
- .px(px(20.))
- .py(px(6.))
- .text_size(px(12.))
- .text_color(if is_active { accent_active } else { text_dim })
- .when(is_active, |d| {
- d.bg(gpui::rgb(0x232a36)).text_color(text)
- })
- .hover(|d| d.bg(gpui::rgb(0x1f2630)))
- .child(label)
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- this.select_view(mod_idx, view_key.clone(), cx);
- })),
- );
- }
- }
- sidebar
- }
-
- fn render_main(
- &mut self,
- cx: &mut Context<Self>,
- panel: gpui::Rgba,
- border: gpui::Rgba,
- text: gpui::Rgba,
- text_dim: gpui::Rgba,
- accent: gpui::Rgba,
- ) -> gpui::Div {
- let main = div()
- .flex_grow()
- .h_full()
- .bg(panel)
- .flex()
- .flex_col()
- .p(px(16.));
-
- let (mod_idx, view_key) = match &self.active {
- Some(a) => (a.0, a.1.clone()),
- None => {
- return main.child(
- div()
- .text_color(text_dim)
- .child("Seleccioná un menú a la izquierda."),
- );
- }
- };
-
- let view = match self
- .modules
- .get(mod_idx)
- .and_then(|m| m.views.get(&view_key))
- {
- Some(v) => v.clone(),
- None => {
- return main.child(
- div()
- .text_color(text_dim)
- .child(format!("Vista no encontrada: {view_key}")),
- );
- }
- };
-
- match view {
- View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
- View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
- }
- }
-
- #[allow(clippy::too_many_arguments)]
- fn render_list(
- &self,
- cx: &mut Context<Self>,
- mut main: gpui::Div,
- lv: &ListView,
- mod_idx: usize,
- border: gpui::Rgba,
- text: gpui::Rgba,
- text_dim: gpui::Rgba,
- accent: gpui::Rgba,
- ) -> gpui::Div {
- let mut header = div()
- .flex()
- .flex_row()
- .items_center()
- .gap(px(12.))
- .mb(px(12.))
- .child(
- div()
- .text_color(text)
- .text_size(px(18.))
- .child(lv.title.clone()),
- );
- for (idx, action) in lv.actions.iter().enumerate() {
- let label = match action {
- Action::OpenView { label, view } => {
- label.clone().unwrap_or_else(|| format!("→ {view}"))
- }
- Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
- Action::Morphism { name, .. } => format!("⚡ {name}"),
- };
- let action_clone = action.clone();
- header = header.child(
- div()
- .id(SharedString::from(format!(
- "list-action-{mod_idx}-{idx}"
- )))
- .px(px(10.))
- .py(px(4.))
- .bg(gpui::rgb(0x232a36))
- .text_color(accent)
- .text_size(px(11.))
- .rounded(px(3.))
- .hover(|d| d.bg(gpui::rgb(0x2c3540)))
- .child(label)
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- this.apply_action(action_clone.clone(), cx);
- })),
- );
- }
- main = main.child(header);
-
- let rows = self.list_rows(&lv.entity);
- let total = rows.len();
-
- let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
- let mut col_header = div()
- .flex()
- .flex_row()
- .py(px(6.))
- .border_b_1()
- .border_color(border)
- .text_color(text_dim)
- .text_size(px(11.));
- for c in &lv.columns {
- let frac = c.weight / total_weight;
- col_header = col_header.child(
- div()
- .flex_grow()
- .flex_basis(px(100. * frac))
- .child(c.label.clone()),
- );
- }
- col_header = col_header
- .child(div().w(px(80.)).text_color(text_dim).child("id"))
- .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
- main = main.child(col_header);
-
- let entity_name = lv.entity.clone();
- for (id, value) in &rows {
- let id_copy = *id;
- let entity_for_edit = entity_name.clone();
- let entity_for_delete = entity_name.clone();
- let mut row = div()
- .flex()
- .flex_row()
- .py(px(6.))
- .border_b_1()
- .border_color(gpui::rgb(0x232a36))
- .text_color(text)
- .text_size(px(12.));
- for c in &lv.columns {
- let frac = c.weight / total_weight;
- let v = lookup_field(value, &c.field);
- row = row.child(
- div()
- .flex_grow()
- .flex_basis(px(100. * frac))
- .child(render_value(v)),
- );
- }
- row = row.child(
- div()
- .w(px(80.))
- .text_color(text_dim)
- .text_size(px(11.))
- .child(short_uuid(id)),
- );
- // Acciones: ✎ edit + ✕ delete por fila.
- row = row.child(
- div()
- .w(px(70.))
- .flex()
- .flex_row()
- .gap(px(4.))
- .child(
- div()
- .id(SharedString::from(format!(
- "row-edit-{mod_idx}-{id_copy}"
- )))
- .px(px(6.))
- .text_color(accent)
- .text_size(px(13.))
- .hover(|d| d.bg(gpui::rgb(0x2c3540)))
- .child("✎")
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
- })),
- )
- .child(
- div()
- .id(SharedString::from(format!(
- "row-del-{mod_idx}-{id_copy}"
- )))
- .px(px(6.))
- .text_color(gpui::rgb(0xd07070))
- .text_size(px(13.))
- .hover(|d| d.bg(gpui::rgb(0x4a2020)))
- .child("✕")
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- // Marca para borrar en lugar de borrar
- // directo: el modal de confirmación se
- // renderea arriba en `render` y maneja
- // confirm/cancel.
- this.pending_delete =
- Some((entity_for_delete.clone(), id_copy));
- this.toast = None;
- cx.notify();
- })),
- ),
- );
- main = main.child(row);
- }
-
- if rows.is_empty() {
- main = main.child(
- div()
- .py(px(12.))
- .text_color(text_dim)
- .text_size(px(12.))
- .child(format!("(sin {})", lv.entity)),
- );
- } else {
- main = main.child(
- div()
- .mt(px(8.))
- .text_color(text_dim)
- .text_size(px(11.))
- .child(format!("{total} fila(s)")),
- );
- }
-
- main
- }
-
- /// Renderea el selector clickable de records existentes para un
- /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
- /// click en una opción setea el TextInput del field con el UUID
- /// seleccionado. El item del UUID actualmente seleccionado (si
- /// hay) se resalta con accent color.
- #[allow(clippy::too_many_arguments)]
- fn render_entity_ref_selector(
- &self,
- cx: &mut Context<Self>,
- field_name: String,
- target_entity: String,
- text: gpui::Rgba,
- text_dim: gpui::Rgba,
- accent: gpui::Rgba,
- ) -> gpui::Div {
- let _ = text;
- let rows = self.list_rows(&target_entity);
- let current = self
- .form_inputs
- .get(&field_name)
- .map(|inp| inp.read(&*cx).text().to_string())
- .unwrap_or_default();
-
- let mut container = div()
- .mt(px(4.))
- .pl(px(8.))
- .border_l_2()
- .border_color(gpui::rgb(0x2a2f38))
- .flex()
- .flex_col()
- .gap(px(2.));
-
- if rows.is_empty() {
- return container.child(
- div()
- .px(px(6.))
- .py(px(4.))
- .text_color(text_dim)
- .text_size(px(10.))
- .child(format!(
- "(sin {target_entity}: creá uno antes para referenciar)"
- )),
- );
- }
-
- container = container.child(
- div()
- .text_color(text_dim)
- .text_size(px(10.))
- .child(format!("Seleccioná un {target_entity}:")),
- );
-
- for (id, value) in &rows {
- let label = human_label_for_record(value, id);
- let id_str = id.to_string();
- let is_selected = current == id_str;
- let field_for_click = field_name.clone();
- let id_for_click = id_str.clone();
- container = container.child(
- div()
- .id(SharedString::from(format!(
- "entity-ref-{field_name}-{id_str}"
- )))
- .px(px(6.))
- .py(px(2.))
- .text_size(px(11.))
- .text_color(if is_selected { accent } else { text_dim })
- .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
- .hover(|d| d.bg(gpui::rgb(0x1f2630)))
- .child(label)
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- if let Some(input) = this.form_inputs.get(&field_for_click) {
- input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
- }
- cx.notify();
- })),
- );
- }
- container
- }
-
- #[allow(clippy::too_many_arguments)]
- fn render_form(
- &self,
- cx: &mut Context<Self>,
- mut main: gpui::Div,
- fv: &FormView,
- mod_idx: usize,
- _border: gpui::Rgba,
- text: gpui::Rgba,
- text_dim: gpui::Rgba,
- accent: gpui::Rgba,
- ) -> gpui::Div {
- // En modo edit, el título refleja eso para que el user no
- // se confunda creyendo que hace alta nueva.
- let title = match self.editing.as_ref() {
- Some((e, id)) if e == &fv.entity => {
- format!("Editar {} {}", fv.entity, short_uuid(id))
- }
- _ => fv.title.clone(),
- };
- main = main.child(
- div()
- .text_color(text)
- .text_size(px(18.))
- .mb(px(12.))
- .child(title),
- );
- for f in &fv.fields {
- let label = if f.required {
- format!("{} *", f.label)
- } else {
- f.label.clone()
- };
-
- let mut field_box = div().flex().flex_col().mb(px(10.)).child(
- div()
- .text_color(text_dim)
- .text_size(px(11.))
- .mb(px(2.))
- .child(label),
- );
-
- // Mount del TextInput vivo (creado en select_view).
- if let Some(input) = self.form_inputs.get(&f.name) {
- field_box = field_box.child(input.clone());
- } else {
- // No debería pasar — select_view crea inputs por cada
- // field. Fallback display estático por seguridad.
- field_box = field_box.child(
- div()
- .px(px(8.))
- .py(px(6.))
- .bg(gpui::rgb(0x171a20))
- .text_color(text_dim)
- .child("(input no inicializado)"),
- );
- }
-
- // Para EntityRef, agregamos un selector clickable de
- // records existentes debajo del TextInput. Click en una
- // opción setea el TextInput interno con el UUID; el
- // submit lee de ahí como cualquier otro field.
- if f.kind == FieldKind::EntityRef {
- if let Some(target_entity) = &f.ref_entity {
- field_box = field_box.child(self.render_entity_ref_selector(
- cx,
- f.name.clone(),
- target_entity.clone(),
- text,
- text_dim,
- accent,
- ));
- }
- }
-
- if let Some(help) = &f.help {
- field_box = field_box.child(
- div()
- .mt(px(2.))
- .text_color(text_dim)
- .text_size(px(10.))
- .child(help.clone()),
- );
- }
- main = main.child(field_box);
- }
-
- let editing_this = matches!(
- self.editing.as_ref(),
- Some((e, _)) if e == &fv.entity
- );
- let submit_label = match &fv.on_submit {
- Action::SeedEntity { entity, .. } => {
- if editing_this {
- format!("Guardar cambios en {entity}")
- } else {
- format!("Crear {entity}")
- }
- }
- Action::Morphism { name, .. } => format!("Ejecutar {name}"),
- Action::OpenView { label, view } => {
- label.clone().unwrap_or_else(|| format!("Ir a {view}"))
- }
- };
- let submit_action = fv.on_submit.clone();
- main = main.child(
- div().mt(px(12.)).child(
- div()
- .id(SharedString::from(format!("form-submit-{mod_idx}")))
- .px(px(12.))
- .py(px(6.))
- .bg(gpui::rgb(0x2c3540))
- .text_color(accent)
- .text_size(px(12.))
- .rounded(px(3.))
- .hover(|d| d.bg(gpui::rgb(0x3a4555)))
- .child(submit_label)
- .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
- this.apply_action(submit_action.clone(), cx);
- })),
- ),
- );
-
- main = main.child(
- div()
- .mt(px(20.))
- .text_color(text_dim)
- .text_size(px(10.))
- .child(
- "Tip: click en el campo para enfocar; Enter no envía (todavía), \
- usá el botón. Backspace borra el último carácter.",
- ),
- );
- main
- }
-}
-
#[cfg(test)]
mod tests {
- use super::*;
+ //! Tests del shell. Los tests del backend impl viven en
+ //! `backend.rs`. Los tests del widget viven en
+ //! `yahweh-widget-meta-form`. Los helpers puros en
+ //! `yahweh-meta-runtime`.
- // Helpers de persistencia movidos al backend en Fase 2b.
- use crate::backend::{maybe_compact_log, snapshot_path_for};
- // Tests E2E de nakui-core que vivieron históricamente en este
- // crate y siguen acá (no son duplicados con yahweh-meta-runtime).
- use nakui_core::event_log::{
- replay_with_snapshot_into, EventLog, LogEntry, Snapshot,
- };
- use nakui_core::store::{MemoryStore, Store};
+ use super::*;
use serde_json::json;
- // NOTA: `parse_field_value` / `parse_field_*` viven y se testean
- // en `yahweh-meta-runtime`. Tests duplicados aquí se borraron en
- // la Fase 2 del refactor yahweh.
-
- #[test]
- fn lookup_field_simple_and_nested() {
- let v = json!({
- "name": "Acme",
- "address": { "city": "Bogotá", "country": "CO" }
- });
- assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
- assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
- assert!(lookup_field(&v, "missing").is_none());
- assert!(lookup_field(&v, "address.zipcode").is_none());
- }
-
- // `infer_param_value`, helpers `spec`/`map`, todos los tests
- // delta_* / clear_fields_* / parse_field_* / resolve_param_* /
- // human_label_* / render_value / value_to_input_text / validate_entity_refs_*
- // viven en `yahweh-meta-runtime`. Borrados en Fase 2 — quedan acá
- // sólo tests de funcionalidad runtime-específica (compact, snapshot,
- // event log, morphism pipeline, load_ui_modules).
-
- #[test]
- fn append_compact_msg_handles_both_branches() {
- // Sin compact: base solo, sin separador.
- let s = append_compact_msg("creado X".into(), None);
- assert_eq!(s.as_ref(), "creado X");
- // Con compact: concatena con "; ".
- let s = append_compact_msg(
- "creado X".into(),
- Some("auto-compact: snapshot @ seq 49".into()),
- );
- assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
- }
-
- /// Simula el ciclo de write+tick que ocurriría en runtime: con
- /// threshold=N, los primeros N-1 writes no compactan, el N-ésimo
- /// dispara compact y resetea el counter, los siguientes vuelven
- /// a acumular.
- ///
- /// Como `tick_runtime_compact` es un método de `MetaUi` (necesita
- /// el cx GPUI para construir el state completo), reproducimos el
- /// algorithm por separado: counter manual + invocación directa de
- /// `maybe_compact_log`. Si la lógica del tick cambia, este test
- /// se va a romper como signal de que hay que actualizarlo.
- #[test]
- fn runtime_compact_cycle_resets_counter_after_threshold() {
- let tmp = tempfile::NamedTempFile::new().unwrap();
- let path = tmp.path().to_path_buf();
- drop(tmp);
- let snap_path = snapshot_path_for(&path);
- let threshold: usize = 3;
-
- let mut store = MemoryStore::new();
- let mut log = EventLog::open(&path).unwrap();
- let mut counter: u64 = 0;
- let mut total_compactions = 0u32;
-
- // 7 writes con threshold=3 → 2 compacts (en write 3 y en
- // write 6), counter restante = 1 al final.
- for i in 0..7u64 {
- let id = Uuid::new_v4();
- log.append(LogEntry::Seed {
- seq: i,
- entity: "row".into(),
- id,
- data: json!({"i": i}),
- schema_hash: None,
- })
- .unwrap();
- store.seed("row", id, json!({"i": i}));
-
- // Tick.
- counter += 1;
- if counter >= threshold as u64 {
- let res =
- maybe_compact_log(&mut log, &snap_path, &store, threshold).unwrap();
- if res.is_some() {
- total_compactions += 1;
- }
- counter = 0;
- }
- }
-
- assert_eq!(
- total_compactions, 2,
- "con 7 writes y threshold 3 deberíamos disparar 2 compacts"
- );
- assert_eq!(counter, 1, "1 write residual sin compactar");
-
- // El log final debería tener: el anchor del último compact +
- // el write residual = 2 entries.
- let entries_after = EventLog::open(&path).unwrap().entries().unwrap().len();
- assert_eq!(
- entries_after, 2,
- "1 anchor del último compact + 1 write residual"
- );
-
- let _ = std::fs::remove_file(&path);
- let _ = std::fs::remove_file(&snap_path);
- }
-
- /// E2E del nuevo `load_ui_modules` que pasa por
- /// `brahman_cards::load_cards_from_dir`. Verifica:
- /// 1. UiModules cargados ordenados por id.
- /// 2. Validación per-module se aplica (un module.json con
- /// menu apuntando a una view inexistente debería fallar).
- /// 3. Cards de otros body kinds (Ente fixture) se reportan
- /// en el `skipped` sin romper la carga.
- #[test]
- fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
- let root = tempfile::tempdir().unwrap();
-
- // Subdir A: UiModule válido.
- let a = root.path().join("alpha");
- std::fs::create_dir(&a).unwrap();
- std::fs::write(
- a.join("module.json"),
- serde_json::to_vec(&json!({
- "id": "alpha",
- "label": "Alpha",
- "entities": [],
- "menu": [],
- "views": {}
- }))
- .unwrap(),
- )
- .unwrap();
-
- // Subdir B: Ente card (no UiModule). Debe skipearse,
- // no romper la carga.
- let b = root.path().join("bravo");
- std::fs::create_dir(&b).unwrap();
- std::fs::write(
- b.join("card.json"),
- serde_json::to_vec(&json!({
- "schema_version": 1,
- "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
- "label": "ente-bravo",
- "payload": "Virtual",
- "supervision": "OneShot"
- }))
- .unwrap(),
- )
- .unwrap();
-
- let (modules, skipped) =
- load_ui_modules(root.path()).expect("load ok");
- assert_eq!(modules.len(), 1);
- assert_eq!(modules[0].id, "alpha");
- assert_eq!(skipped.len(), 1);
- assert!(skipped[0].contains("ente"));
- }
-
- #[test]
- fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
- let root = tempfile::tempdir().unwrap();
- let sub = root.path().join("broken");
- std::fs::create_dir(&sub).unwrap();
- // menu apunta a una view que no existe en `views`.
- std::fs::write(
- sub.join("module.json"),
- serde_json::to_vec(&json!({
- "id": "broken",
- "label": "Broken",
- "entities": [],
- "menu": [{ "label": "Phantom", "view": "ghost" }],
- "views": {}
- }))
- .unwrap(),
- )
- .unwrap();
- let err = load_ui_modules(root.path()).unwrap_err();
- assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
- }
-
- #[test]
- fn load_ui_modules_detects_duplicate_id() {
- let root = tempfile::tempdir().unwrap();
- for name in ["dir_a", "dir_b"] {
- let sub = root.path().join(name);
- std::fs::create_dir(&sub).unwrap();
- std::fs::write(
- sub.join("module.json"),
- serde_json::to_vec(&json!({
- "id": "dup",
- "label": "Dup",
- "entities": [], "menu": [], "views": {}
- }))
- .unwrap(),
- )
- .unwrap();
- }
- let err = load_ui_modules(root.path()).unwrap_err();
- assert!(err.contains("duplicado"), "msg debe decir duplicado: {err}");
- assert!(err.contains("dup"), "msg debe nombrar el id: {err}");
- }
-
- #[test]
- fn snapshot_path_for_replaces_extension() {
- use std::path::Path;
- assert_eq!(
- snapshot_path_for(Path::new("nakui-ui-state.jsonl")),
- std::path::PathBuf::from("nakui-ui-state.snap.json"),
- );
- // Sin extensión: agrega .snap.json.
- assert_eq!(
- snapshot_path_for(Path::new("/tmp/foo")),
- std::path::PathBuf::from("/tmp/foo.snap.json"),
- );
- }
-
- #[test]
- fn maybe_compact_log_below_threshold_noops() {
- let tmp = tempfile::NamedTempFile::new().unwrap();
- let path = tmp.path().to_path_buf();
- drop(tmp);
- let snap_path = snapshot_path_for(&path);
- let mut log = EventLog::open(&path).unwrap();
- for i in 0..5 {
- log.append(LogEntry::Seed {
- seq: i,
- entity: "x".into(),
- id: Uuid::new_v4(),
- data: json!({"i": i}),
- schema_hash: None,
- })
- .unwrap();
- }
- let store = MemoryStore::new();
- let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
- assert!(res.is_none(), "5 < 50 → no debería compactar");
- assert_eq!(log.entries().unwrap().len(), 5, "log intacto");
- assert!(!snap_path.exists(), "no debería haber escrito snapshot");
- let _ = std::fs::remove_file(&path);
- }
-
- #[test]
- fn maybe_compact_log_threshold_zero_noops() {
- let tmp = tempfile::NamedTempFile::new().unwrap();
- let path = tmp.path().to_path_buf();
- drop(tmp);
- let snap_path = snapshot_path_for(&path);
- let mut log = EventLog::open(&path).unwrap();
- for i in 0..3 {
- log.append(LogEntry::Seed {
- seq: i,
- entity: "x".into(),
- id: Uuid::new_v4(),
- data: json!({"i": i}),
- schema_hash: None,
- })
- .unwrap();
- }
- let store = MemoryStore::new();
- let res = maybe_compact_log(&mut log, &snap_path, &store, 0).unwrap();
- assert!(res.is_none(), "threshold 0 = disabled");
- let _ = std::fs::remove_file(&path);
- }
-
- /// E2E del ciclo completo: write log con N entries por encima del
- /// threshold → maybe_compact_log captura snapshot y trunca el log
- /// → re-open + replay con snapshot → store final == store que
- /// resultaría de full replay sin snapshot.
- #[test]
- fn maybe_compact_log_then_reopen_preserves_records() {
- let tmp = tempfile::NamedTempFile::new().unwrap();
- let path = tmp.path().to_path_buf();
- drop(tmp);
- let snap_path = snapshot_path_for(&path);
-
- // 1. Escribir 60 seeds + popular store en sync.
- let mut store = MemoryStore::new();
- let mut ids = Vec::new();
- let mut log = EventLog::open(&path).unwrap();
- for i in 0..60u64 {
- let id = Uuid::new_v4();
- log.append(LogEntry::Seed {
- seq: i,
- entity: "row".into(),
- id,
- data: json!({"i": i}),
- schema_hash: None,
- })
- .unwrap();
- store.seed("row", id, json!({"i": i}));
- ids.push(id);
- }
- assert_eq!(log.next_seq(), 60);
- assert_eq!(log.entries().unwrap().len(), 60);
-
- // 2. Compactar (60 >= 50).
- let res = maybe_compact_log(&mut log, &snap_path, &store, 50).unwrap();
- assert!(res.is_some(), "60 >= 50 debe compactar");
- let msg = res.unwrap();
- assert!(msg.contains("seq 59"), "msg debe incluir el seq: {msg}");
- assert!(
- msg.contains("59 entries dropped"),
- "msg debe reportar 59 dropped (60 - 1 anchor): {msg}"
- );
-
- // 3. Verificar: snapshot existe, log queda con 1 entry
- // (anchor del cursor), next_seq se preserva (60).
- assert!(snap_path.exists(), "snapshot debería existir");
- let log_after = EventLog::open(&path).unwrap();
- assert_eq!(
- log_after.entries().unwrap().len(),
- 1,
- "log debería tener 1 anchor entry tras compact"
- );
- assert_eq!(
- log_after.next_seq(),
- 60,
- "next_seq se preserva via anchor entry"
- );
-
- // 4. Re-open + replay desde snapshot → todos los records.
- // El anchor entry cae bajo snap.seq así que se skipea.
- let snap = Snapshot::load(&snap_path).unwrap().expect("snap loadeable");
- assert_eq!(snap.seq, 59);
- let mut fresh_store = MemoryStore::new();
- replay_with_snapshot_into(&log_after, Some(&snap), &mut fresh_store).unwrap();
- for (i, id) in ids.iter().enumerate() {
- assert_eq!(
- fresh_store.load("row", *id),
- Some(json!({"i": i as u64})),
- "record {i} debería estar tras snapshot+replay"
- );
- }
-
- // 5. Idempotencia: segunda corrida del compact con threshold=1
- // no hace nada — queda 1 anchor entry, no hay nada útil
- // que dropear.
- let mut log_reopened = EventLog::open(&path).unwrap();
- let res2 =
- maybe_compact_log(&mut log_reopened, &snap_path, &fresh_store, 1).unwrap();
- assert!(
- res2.is_none(),
- "post-compact: 1 anchor entry, segundo compact debe ser no-op"
- );
-
- let _ = std::fs::remove_file(&path);
- let _ = std::fs::remove_file(&snap_path);
- }
-
- #[test]
- fn value_to_input_then_parse_round_trip() {
- // El round-trip es la propiedad fundamental: edit → text →
- // parse → mismo Value (modulo casts numéricos).
- let cases = vec![
- (FieldKind::Text, json!("hola")),
- (FieldKind::Boolean, json!(true)),
- (FieldKind::Boolean, json!(false)),
- (FieldKind::Number, json!(42)),
- ];
- for (kind, original) in cases {
- let text = value_to_input_text(&original);
- let parsed = parse_field_value(kind, &text).unwrap();
- assert_eq!(
- parsed, original,
- "round-trip text→parse falló para {original:?}"
- );
- }
- }
-
/// E2E mínimo del WAL: armamos un log a mano con dos seeds,
/// abrimos con `EventLog::open` + `replay_into`, y verificamos
/// que el `MemoryStore` queda con esos records aplicados.
- /// Esto reproduce el flujo del startup de `MetaUi::new` sin
- /// necesitar GPUI.
+ /// Reproduce el flujo del startup de NakuiBackend sin GPUI.
#[test]
fn event_log_replay_restores_memory_store() {
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
use nakui_core::store::{MemoryStore, Store};
+ use uuid::Uuid;
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
- // Cerramos el handle de tempfile pero conservamos el path
- // para que EventLog pueda re-abrir.
drop(tmp);
- // Escribimos dos seeds via EventLog::append.
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
{
@@ -1722,50 +232,40 @@ mod tests {
.unwrap();
}
- // Re-abrir + replay (simula startup de MetaUi).
let log = EventLog::open(&path).unwrap();
- assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
+ assert_eq!(log.next_seq(), 2);
let mut store = MemoryStore::new();
replay_into(&log, &mut store).unwrap();
- // Verificar que ambos records están en el store.
assert_eq!(
store.load("customer", id_a),
- Some(json!({"name": "Acme"})),
- "Acme debería estar tras replay"
+ Some(json!({"name": "Acme"}))
);
assert_eq!(
store.load("customer", id_b),
- Some(json!({"name": "Globex"})),
- "Globex debería estar tras replay"
+ Some(json!({"name": "Globex"}))
);
let _ = std::fs::remove_file(&path);
}
/// E2E del Action::Morphism: carga el módulo nakui-core real
- /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
- /// arma store + log, y ejecuta el morphism `vender` vía
- /// `execute_and_log_with_recovery` (la misma función que
- /// `commit_morphism` invoca). Verifica que las ops esperadas
- /// se loguean y aplican (stock decrementa, caja incrementa).
- ///
- /// Reproduce el flujo del runtime sin necesitar GPUI.
+ /// `sales`, arma store + log, y ejecuta el morphism `vender` vía
+ /// `execute_and_log_with_recovery` (la función que usa
+ /// `NakuiBackend::morphism` internamente). Verifica las
+ /// post-condiciones esperadas del manifest sales.
#[test]
fn morphism_pipeline_executes_real_sales_vender() {
use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
+ use uuid::Uuid;
- // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
- // → crates/modules/nakui/modules/sales).
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let sales_dir = here
.join("../../..")
.join("crates/modules/nakui/modules/sales");
if !sales_dir.join("nsmc.json").exists() {
- // Si el módulo demo no está donde esperamos, skipeamos
- // — no es regresión del feature, es ambiente.
eprintln!(
"skip: sales module no encontrado en {}",
sales_dir.display()
@@ -1823,137 +323,102 @@ mod tests {
)
.expect("morphism vender debe ejecutar limpio");
- assert!(!ops.is_empty(), "vender debería producir ops");
-
- // Sanity post-condiciones esperadas del manifest sales:
- // - stock.cantidad bajó (vendimos 5).
+ assert!(!ops.is_empty());
let stock_after = store
.load("Stock", stock_id)
- .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
+ .and_then(|v| v.get("cantidad").and_then(|c| c.as_i64()))
.expect("stock con cantidad");
- assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
- // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
- // inicial 1_000_000).
+ assert_eq!(stock_after, 95);
let caja_after = store
.load("Caja", caja_id)
- .and_then(|v| v.get("saldo").and_then(Value::as_i64))
+ .and_then(|v| v.get("saldo").and_then(|s| s.as_i64()))
.expect("caja con saldo");
- assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
+ assert_eq!(caja_after, 1_001_000);
let _ = std::fs::remove_file(&log_path);
}
- /// E2E del ciclo CRUD vía log:
- /// 1. Seed un record.
- /// 2. Morphism con Set ops (edit) — sobreescribe campos.
- /// 3. Morphism con Delete op — borra el record.
- /// 4. Replay desde cero: el store queda como tras el delete (vacío).
#[test]
- fn event_log_replay_handles_full_crud_cycle() {
- use nakui_core::delta::{FieldOp, FieldPath};
- use nakui_core::event_log::{replay_into, EventLog, LogEntry};
- use nakui_core::store::{MemoryStore, Store};
+ fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
+ let root = tempfile::tempdir().unwrap();
- let tmp = tempfile::NamedTempFile::new().unwrap();
- let path = tmp.path().to_path_buf();
- drop(tmp);
+ let a = root.path().join("alpha");
+ std::fs::create_dir(&a).unwrap();
+ std::fs::write(
+ a.join("module.json"),
+ serde_json::to_vec(&json!({
+ "id": "alpha",
+ "label": "Alpha",
+ "entities": [],
+ "menu": [],
+ "views": {}
+ }))
+ .unwrap(),
+ )
+ .unwrap();
- let id = Uuid::new_v4();
+ let b = root.path().join("bravo");
+ std::fs::create_dir(&b).unwrap();
+ std::fs::write(
+ b.join("card.json"),
+ serde_json::to_vec(&json!({
+ "schema_version": 1,
+ "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
+ "label": "ente-bravo",
+ "payload": "Virtual",
+ "supervision": "OneShot"
+ }))
+ .unwrap(),
+ )
+ .unwrap();
- // 1. Escribir 3 entries: seed, edit, delete.
- {
- let mut log = EventLog::open(&path).unwrap();
- log.append(LogEntry::Seed {
- seq: 0,
- entity: "customer".into(),
- id,
- data: json!({"name": "Acme", "active": true}),
- schema_hash: None,
- })
- .unwrap();
- log.append(LogEntry::Morphism {
- seq: 1,
- morphism: "ui.edit_record".into(),
- inputs: Default::default(),
- params: json!({}),
- ops: vec![
- FieldOp::Set {
- path: FieldPath {
- entity: "customer".into(),
- id,
- field: "name".into(),
- },
- value: json!("Acme S.A."),
- },
- FieldOp::Set {
- path: FieldPath {
- entity: "customer".into(),
- id,
- field: "active".into(),
- },
- value: json!(false),
- },
- ],
- schema_hash: None,
- })
- .unwrap();
- log.append(LogEntry::Morphism {
- seq: 2,
- morphism: "ui.delete_record".into(),
- inputs: Default::default(),
- params: json!({}),
- ops: vec![FieldOp::Delete {
- entity: "customer".into(),
- id,
- }],
- schema_hash: None,
- })
- .unwrap();
- }
+ let (modules, skipped) = load_ui_modules(root.path()).expect("load ok");
+ assert_eq!(modules.len(), 1);
+ assert_eq!(modules[0].id, "alpha");
+ assert_eq!(skipped.len(), 1);
+ assert!(skipped[0].contains("ente"));
+ }
- // 2. Replay desde cero — debe terminar con store vacío
- // (el delete fue el último op).
- let log = EventLog::open(&path).unwrap();
- let mut store = MemoryStore::new();
- replay_into(&log, &mut store).unwrap();
- assert_eq!(
- store.load("customer", id),
- None,
- "tras seed + edit + delete, el record no debería existir"
- );
+ #[test]
+ fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
+ let root = tempfile::tempdir().unwrap();
+ let sub = root.path().join("broken");
+ std::fs::create_dir(&sub).unwrap();
+ std::fs::write(
+ sub.join("module.json"),
+ serde_json::to_vec(&json!({
+ "id": "broken",
+ "label": "Broken",
+ "entities": [],
+ "menu": [{ "label": "Phantom", "view": "ghost" }],
+ "views": {}
+ }))
+ .unwrap(),
+ )
+ .unwrap();
+ let err = load_ui_modules(root.path()).unwrap_err();
+ assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
+ }
- // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
- // record debería tener los valores editados.
- // (Construimos un store fresh y aplicamos sólo seq 0 y 1
- // a mano para verificar.)
- let mut store_partial = MemoryStore::new();
- store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
- store_partial
- .apply(&[
- FieldOp::Set {
- path: FieldPath {
- entity: "customer".into(),
- id,
- field: "name".into(),
- },
- value: json!("Acme S.A."),
- },
- FieldOp::Set {
- path: FieldPath {
- entity: "customer".into(),
- id,
- field: "active".into(),
- },
- value: json!(false),
- },
- ])
+ #[test]
+ fn load_ui_modules_detects_duplicate_id() {
+ let root = tempfile::tempdir().unwrap();
+ for name in ["dir_a", "dir_b"] {
+ let sub = root.path().join(name);
+ std::fs::create_dir(&sub).unwrap();
+ std::fs::write(
+ sub.join("module.json"),
+ serde_json::to_vec(&json!({
+ "id": "dup",
+ "label": "Dup",
+ "entities": [], "menu": [], "views": {}
+ }))
+ .unwrap(),
+ )
.unwrap();
- assert_eq!(
- store_partial.load("customer", id),
- Some(json!({"name": "Acme S.A.", "active": false})),
- "tras seed + edit, el record debería tener los nuevos valores"
- );
-
- let _ = std::fs::remove_file(&path);
+ }
+ let err = load_ui_modules(root.path()).unwrap_err();
+ assert!(err.contains("duplicado"));
+ assert!(err.contains("dup"));
}
}
diff --git a/crates/modules/ui_engine/widgets/meta-form/Cargo.toml b/crates/modules/ui_engine/widgets/meta-form/Cargo.toml
new file mode 100644
index 0000000..28c8cc7
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/meta-form/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "yahweh-widget-meta-form"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Yahweh — widget metainterfaz: lista, formulario, modal de delete y selector EntityRef que renderean cualquier `yahweh-meta-schema::Module` contra cualquier `yahweh_meta_runtime::MetaBackend`. App-agnostic."
+
+[dependencies]
+gpui = { workspace = true }
+serde_json = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+yahweh-meta-runtime = { path = "../../libs/meta-runtime" }
+yahweh-meta-schema = { path = "../../libs/meta-schema" }
+yahweh-theme = { path = "../../libs/theme" }
+yahweh-widget-text-input = { path = "../text_input" }
diff --git a/crates/modules/ui_engine/widgets/meta-form/src/lib.rs b/crates/modules/ui_engine/widgets/meta-form/src/lib.rs
new file mode 100644
index 0000000..563db0c
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/meta-form/src/lib.rs
@@ -0,0 +1,1204 @@
+//! `yahweh-widget-meta-form` — widget GPUI de metainterfaz.
+//!
+//! Renderea cualquier conjunto de [`yahweh_meta_schema::Module`] sobre
+//! cualquier impl de [`yahweh_meta_runtime::MetaBackend`]: sidebar con
+//! menús, list/form views, modal de delete, EntityRef selector.
+//!
+//! El widget es **app-agnostic**: no asume Nakui, ni storage
+//! particular, ni lifecycle de bootstrapping. El binario que lo
+//! consume:
+//!
+//! 1. Carga sus módulos (típicamente via `brahman-cards`).
+//! 2. Construye su backend concreto (typicamente wireado a un
+//! store/log/executor de su stack).
+//! 3. Crea `MetaApp::new(modules, backend, initial_toast,
+//! initial_error, cx)` y lo monta como root view de GPUI.
+//!
+//! Para una metainterfaz Nakui-completa ver `nakui-ui` (binario
+//! shell que provee `NakuiBackend`).
+
+#![forbid(unsafe_code)]
+
+use std::collections::BTreeMap;
+
+use gpui::{
+ div, prelude::*, px, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render,
+ SharedString, Window,
+};
+
+use serde_json::Value;
+use uuid::Uuid;
+use yahweh_meta_runtime::{
+ compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value,
+ render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text,
+ MetaBackend, WriteOutcome,
+};
+use yahweh_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Module, View};
+use yahweh_widget_text_input::TextInput;
+
+/// Estado del runtime de UI. Toda la persistencia/ejecución está
+/// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI
+/// state y el schema de los módulos.
+///
+/// Genérico sobre el backend `B`. Un binario decide qué backend
+/// proveer (Nakui via `NakuiBackend`, mock para tests, etc.).
+pub struct MetaApp<B: MetaBackend> {
+ /// Módulos cargados, ordenados por id.
+ modules: Vec<Module>,
+ /// Backend que ejecuta seed/update/delete/morphism.
+ backend: B,
+ /// (módulo idx, vista key) actualmente activos.
+ active: Option<(usize, String)>,
+ /// Inputs vivos para el form actual: nombre del campo → TextInput.
+ /// Se reemplaza al cambiar de vista (drop de los anteriores).
+ form_inputs: BTreeMap<String, Entity<TextInput>>,
+ /// Si está set, el próximo render del Form pre-llena los inputs
+ /// con los valores del record indicado, y `commit_seed` emite
+ /// un `update` (no un seed nuevo). Limpia al cambiar de view o
+ /// tras submit exitoso.
+ editing: Option<(String, Uuid)>,
+ /// Si está set, el banner modal de confirmación de delete está
+ /// activo: `(entity, id)` del record que el usuario marcó para
+ /// borrar. Permanece hasta que el usuario click [Confirmar]
+ /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia).
+ /// Navegación a otra view también cancela.
+ pending_delete: Option<(String, Uuid)>,
+ /// Mensaje toast al pie (success de submit, error de carga, etc.).
+ toast: Option<SharedString>,
+ /// Si la carga de módulos falló al inicio.
+ load_error: Option<SharedString>,
+}
+
+impl<B: MetaBackend> MetaApp<B> {
+ /// Constructor del widget. El caller pre-construye sus módulos +
+ /// backend + cualquier mensaje inicial que quiera mostrar al
+ /// usuario al abrir (típicamente el toast del replay y el error
+ /// banner de cosas que fallaron de bootstrap).
+ ///
+ /// La active view default es la primera entry del menú del
+ /// primer módulo (orden lexicográfico por id).
+ pub fn new(
+ modules: Vec<Module>,
+ backend: B,
+ initial_toast: Option<String>,
+ initial_error: Option<String>,
+ _cx: &mut Context<Self>,
+ ) -> Self {
+ let active = modules
+ .first()
+ .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
+ Self {
+ modules,
+ backend,
+ active,
+ form_inputs: BTreeMap::new(),
+ editing: None,
+ pending_delete: None,
+ toast: initial_toast.map(SharedString::from),
+ load_error: initial_error.map(SharedString::from),
+ }
+ }
+
+ /// Cambia la vista activa. Si la nueva vista es un Form, crea
+ /// `TextInput` entities para cada field. Pre-llena con valores
+ /// del record si hay `editing` para esa entity; si no, usa el
+ /// `default` del schema.
+ /// Drop de los inputs anteriores ocurre al sobreescribir el map.
+ fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
+ self.active = Some((mod_idx, view_key.clone()));
+ self.toast = None;
+ // Navegar a otra view cancela cualquier delete pendiente:
+ // el record marcado puede no estar visible en la nueva view.
+ self.pending_delete = None;
+ self.form_inputs = BTreeMap::new();
+ if let Some(module) = self.modules.get(mod_idx) {
+ if let Some(View::Form(form)) = module.views.get(&view_key) {
+ // Snapshot del record si estamos editando esta entity.
+ let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
+ if e == &form.entity {
+ self.backend.load_record(e, *id)
+ } else {
+ None
+ }
+ });
+ for f in &form.fields {
+ let initial = if let Some(rec) = &editing_record {
+ rec.get(&f.name)
+ .map(value_to_input_text)
+ .unwrap_or_else(|| f.default.clone().unwrap_or_default())
+ } else {
+ f.default.clone().unwrap_or_default()
+ };
+ let input = cx.new(|cx| TextInput::new(initial, cx));
+ self.form_inputs.insert(f.name.clone(), input);
+ }
+ } else {
+ // Cambiar a una view que no es Form invalida el editing
+ // pendiente.
+ self.editing = None;
+ }
+ }
+ cx.notify();
+ }
+
+ /// Inicia un edit del record: setea `editing` y abre la primera
+ /// view de tipo Form del módulo (convención: la del schema).
+ fn open_edit(
+ &mut self,
+ mod_idx: usize,
+ entity: String,
+ id: Uuid,
+ cx: &mut Context<Self>,
+ ) {
+ self.editing = Some((entity.clone(), id));
+ let form_view_key = self.modules.get(mod_idx).and_then(|m| {
+ m.views
+ .iter()
+ .find_map(|(key, v)| match v {
+ View::Form(form) if form.entity == entity => Some(key.clone()),
+ _ => None,
+ })
+ });
+ match form_view_key {
+ Some(key) => self.select_view(mod_idx, key, cx),
+ None => {
+ self.toast = Some(SharedString::from(format!(
+ "no hay form view para entity '{entity}' en este módulo"
+ )));
+ self.editing = None;
+ cx.notify();
+ }
+ }
+ }
+
+ /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
+ /// aplica al store via `apply` (no via remove directo, mantiene
+ /// el modelo de "todo cambio post-seed pasa por ops").
+ /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome
+ /// del backend (incluye eventual `post_status` del compact tick).
+ fn commit_delete(
+ &mut self,
+ entity: &str,
+ id: Uuid,
+ ) -> Result<WriteOutcome, String> {
+ self.backend.delete(entity, id)
+ }
+
+ /// Aplica una acción (click en menú, botón de form, action de
+ /// list). Mutaciones contra el backend ocurren acá.
+ fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
+ let mod_idx = match self.active.as_ref() {
+ Some((i, _)) => *i,
+ None => return,
+ };
+ match action {
+ Action::OpenView { view, .. } => {
+ // Salir a otra view cancela el edit pendiente.
+ self.editing = None;
+ self.select_view(mod_idx, view, cx);
+ }
+ Action::SeedEntity { entity, next_view } => {
+ let was_editing = self.editing.is_some();
+ match self.commit_seed(mod_idx, &entity, cx) {
+ Ok(outcome) => {
+ let toast_msg = format_seed_toast(&entity, was_editing, &outcome);
+ self.toast = Some(append_compact_msg(toast_msg, outcome.post_status));
+ // Limpia editing tras un commit exitoso —
+ // el record ya está sincronizado (incluso
+ // un NoChange cierra el modo edit).
+ self.editing = None;
+ if let Some(v) = next_view {
+ self.select_view(mod_idx, v, cx);
+ } else {
+ for input in self.form_inputs.values() {
+ input.update(cx, |inp, cx| inp.set_text("", cx));
+ }
+ }
+ }
+ Err(e) => {
+ self.toast = Some(SharedString::from(format!("error: {e}")));
+ }
+ }
+ cx.notify();
+ }
+ Action::Morphism {
+ name,
+ inputs,
+ params,
+ next_view,
+ } => {
+ match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
+ Ok(outcome) => {
+ let base = format!(
+ "morphism '{name}' OK ({} op(s) aplicadas)",
+ outcome.changed
+ );
+ self.toast = Some(append_compact_msg(base, outcome.post_status));
+ if let Some(v) = next_view {
+ self.select_view(mod_idx, v, cx);
+ }
+ }
+ Err(e) => {
+ self.toast = Some(SharedString::from(format!(
+ "morphism '{name}' falló: {e}"
+ )));
+ }
+ }
+ cx.notify();
+ }
+ }
+ }
+
+ /// Despacha un morphism via el backend. Resuelve inputs (UUIDs)
+ /// + params (Value object) leyendo los TextInput del form.
+ fn commit_morphism(
+ &mut self,
+ mod_idx: usize,
+ morphism: &str,
+ inputs_map: &BTreeMap<String, String>,
+ params_fields: &[String],
+ cx: &mut Context<Self>,
+ ) -> Result<WriteOutcome, String> {
+ let module = self
+ .modules
+ .get(mod_idx)
+ .ok_or_else(|| "módulo inválido".to_string())?;
+ let module_id = module.id.clone();
+
+ // Resolver inputs: por cada (role, field_name), parsear el
+ // value del input como Uuid.
+ let mut inputs: BTreeMap<String, Uuid> = BTreeMap::new();
+ for (role, field_name) in inputs_map {
+ let raw = self
+ .form_inputs
+ .get(field_name)
+ .map(|inp| inp.read(&*cx).text().to_string())
+ .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
+ let id = Uuid::parse_str(raw.trim()).map_err(|_| {
+ format!(
+ "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
+ )
+ })?;
+ inputs.insert(role.clone(), id);
+ }
+
+ // Resolver params: si la lista está vacía, todos los fields
+ // del form que no estén en `inputs_map` van a params.
+ let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
+ let mut params_obj = serde_json::Map::new();
+ let field_iter: Vec<String> = if params_fields.is_empty() {
+ self.form_inputs
+ .keys()
+ .filter(|k| !input_field_set.contains(*k))
+ .cloned()
+ .collect()
+ } else {
+ params_fields.to_vec()
+ };
+
+ // FieldSpec del Form view activo para parseo estricto por kind.
+ let active_form_fields: Option<Vec<FieldSpec>> =
+ self.active.as_ref().and_then(|(_, vk)| {
+ module.views.get(vk).and_then(|v| match v {
+ View::Form(f) => Some(f.fields.clone()),
+ _ => None,
+ })
+ });
+
+ for field_name in field_iter {
+ let raw = self
+ .form_inputs
+ .get(&field_name)
+ .map(|inp| inp.read(&*cx).text().to_string())
+ .unwrap_or_default();
+ let spec = active_form_fields
+ .as_ref()
+ .and_then(|fs| fs.iter().find(|f| f.name == field_name));
+ let value = resolve_param_value(&field_name, &raw, spec)?;
+ params_obj.insert(field_name, value);
+ }
+
+ self.backend
+ .morphism(&module_id, morphism, inputs, Value::Object(params_obj))
+ }
+
+ /// Construye un payload desde los TextInput vivos y delega al
+ /// backend (`seed` para alta nueva, `update` con set+clear para
+ /// edit). Devuelve el `WriteOutcome` del backend (incluye
+ /// `changed` y `post_status` para el toast).
+ fn commit_seed(
+ &mut self,
+ mod_idx: usize,
+ entity: &str,
+ cx: &mut Context<Self>,
+ ) -> Result<WriteOutcome, String> {
+ let module = &self.modules[mod_idx];
+ let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
+ Some((_, view_key)) => match module.views.get(view_key) {
+ Some(View::Form(f)) => f.fields.clone(),
+ _ => return Err("la vista activa no es un Form".into()),
+ },
+ None => return Err("ninguna vista activa".into()),
+ };
+ let mut obj = serde_json::Map::new();
+ // Fields que el form deja vacíos y son optional: candidatos
+ // a Clear en el path de EDIT.
+ let mut to_clear: Vec<String> = Vec::new();
+ // EntityRef refs a validar tras parse loop (en una toma del
+ // backend en lugar de N).
+ let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new();
+ for f in &spec_fields {
+ let raw = self
+ .form_inputs
+ .get(&f.name)
+ .map(|input| input.read(cx).text().to_string())
+ .unwrap_or_default();
+ if f.required && raw.trim().is_empty() {
+ return Err(format!("campo '{}' es obligatorio", f.label));
+ }
+ if raw.is_empty() && !f.required {
+ to_clear.push(f.name.clone());
+ continue;
+ }
+ let value = parse_field_value(f.kind, &raw)
+ .map_err(|e| format!("campo '{}': {e}", f.label))?;
+ if f.kind == FieldKind::EntityRef {
+ if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) {
+ let id = Uuid::parse_str(uuid_str)
+ .expect("parse_field_value validated UUID");
+ entity_refs.push((f.label.clone(), target.clone(), id));
+ }
+ }
+ obj.insert(f.name.clone(), value);
+ }
+ // Validar EntityRefs contra el backend actual. Cierre wrappea
+ // backend.load_record para mantener la firma de
+ // validate_entity_refs (que es store-agnóstica).
+ if !entity_refs.is_empty() {
+ let backend = &self.backend;
+ validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?;
+ }
+
+ // Ramificación: edit (hay editing para esta entity) vs alta.
+ let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
+
+ if let Some((_, id)) = editing_match {
+ // EDIT: cargar current, computar delta, llamar
+ // backend.update con set+clear pre-computados.
+ let current = self.backend.load_record(entity, id).unwrap_or(Value::Null);
+ let set_delta = compute_field_delta(¤t, &obj);
+ let clear_fields = compute_clear_fields(¤t, &to_clear);
+ self.backend.update(entity, id, set_delta, clear_fields)
+ } else {
+ // SEED: alta nueva — el backend genera el Uuid.
+ self.backend.seed(entity, obj)
+ }
+ }
+
+ /// Snapshot ordenado de records de una entity (proxy al backend).
+ fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
+ self.backend.list_records(entity)
+ }
+}
+
+/// Formatea el toast para la rama Action::SeedEntity según el
+/// `WriteOutcome` del backend. `was_editing` distingue "creado"
+/// vs "actualizado" — el WriteOutcome solo no alcanza porque
+/// `seed` y `update` ambos devuelven `id = Some(...)`.
+fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String {
+ let id_short = outcome
+ .id
+ .map(|id| short_uuid(&id))
+ .unwrap_or_default();
+ match (was_editing, outcome.changed) {
+ (false, _) => format!("creado {entity} {id_short}"),
+ (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"),
+ (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"),
+ }
+}
+
+/// Concatena el msg opcional del compact tick al toast del op
+/// original con `"; "` separator.
+fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
+ match compact_msg {
+ Some(m) => SharedString::from(format!("{base}; {m}")),
+ None => SharedString::from(base),
+ }
+}
+
+/// Walker dentro de un `Value` por path con `.` como separador.
+/// Local porque sólo lo usa la lista renderer y no tiene tests
+/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
+fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
+ let mut cur = v;
+ for seg in path.split('.') {
+ cur = cur.get(seg)?;
+ }
+ Some(cur)
+}
+
+impl<B: MetaBackend> Render for MetaApp<B> {
+ fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let bg = gpui::rgb(0x14171c);
+ let panel = gpui::rgb(0x1d2128);
+ let border = gpui::rgb(0x2a2f38);
+ let text = gpui::rgb(0xe6e8ec);
+ let text_dim = gpui::rgb(0x9ba1ad);
+ let accent = gpui::rgb(0x88c0d0);
+ let accent_active = gpui::rgb(0xa3be8c);
+
+ let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
+ let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
+ let confirm_banner = self.render_confirm_delete_banner(cx);
+ let toast_div = self.toast.as_ref().map(|t| {
+ div()
+ .px(px(12.))
+ .py(px(6.))
+ .bg(gpui::rgb(0x2d3a2a))
+ .text_color(gpui::rgb(0xc0e0a0))
+ .text_size(px(11.))
+ .child(t.clone())
+ });
+ let error_banner = self.load_error.as_ref().map(|e| {
+ div()
+ .px(px(12.))
+ .py(px(6.))
+ .bg(gpui::rgb(0x4a2020))
+ .text_color(gpui::rgb(0xffd0d0))
+ .text_size(px(11.))
+ .child(e.clone())
+ });
+
+ div()
+ .flex()
+ .flex_col()
+ .size_full()
+ .bg(bg)
+ // Capture phase: el Esc llega al root ANTES que cualquier
+ // TextInput descendiente. Si hay un delete pendiente, lo
+ // cancelamos. Sin pending no hacemos nada (el evento sigue
+ // su flujo normal y el TextInput recibe el Esc bubble).
+ .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
+ if event.keystroke.key != "escape" {
+ return;
+ }
+ if let Some((entity, _id)) = this.pending_delete.take() {
+ this.toast = Some(SharedString::from(format!(
+ "delete cancelado ({entity}) [esc]"
+ )));
+ cx.notify();
+ }
+ }))
+ .when_some(error_banner, |d, b| d.child(b))
+ .when_some(confirm_banner, |d, b| d.child(b))
+ .child(
+ div()
+ .flex()
+ .flex_row()
+ .flex_grow()
+ .child(sidebar)
+ .child(main_panel),
+ )
+ .when_some(toast_div, |d, t| d.child(t))
+ }
+}
+
+impl<B: MetaBackend> MetaApp<B> {
+ /// Renderea el banner modal de confirmación cuando hay un delete
+ /// pendiente. Devuelve `None` si no hay nada que confirmar.
+ ///
+ /// UX: banner amber prominente arriba de todo el contenido. No es
+ /// un overlay flotante (GPUI no expone z-index fácilmente sin
+ /// setup) — es un row del flex_col raíz, así que el contenido
+ /// debajo se desplaza unos pixels mientras está activo. Suficiente
+ /// para forzar al usuario a leer + click.
+ fn render_confirm_delete_banner(&self, cx: &mut Context<Self>) -> Option<gpui::Div> {
+ let (entity, id) = self.pending_delete.as_ref()?;
+ let entity_owned = entity.clone();
+ let id_owned = *id;
+ let entity_for_cancel = entity_owned.clone();
+ let id_short = short_uuid(&id_owned);
+ let entity_for_confirm = entity_owned.clone();
+
+ let banner_bg = gpui::rgb(0x4a3a1a);
+ let banner_text = gpui::rgb(0xf0e0a0);
+ let confirm_bg = gpui::rgb(0x6a2222);
+ let confirm_text = gpui::rgb(0xffd0d0);
+ let cancel_bg = gpui::rgb(0x2a2f38);
+ let cancel_text = gpui::rgb(0xc0c8d0);
+
+ Some(
+ div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(12.))
+ .px(px(12.))
+ .py(px(8.))
+ .bg(banner_bg)
+ .text_color(banner_text)
+ .text_size(px(12.))
+ .child(
+ div()
+ .flex_grow()
+ .flex()
+ .flex_col()
+ .child(format!("¿Borrar {entity_owned} {id_short}?"))
+ .child(
+ div()
+ .text_size(px(10.))
+ .text_color(gpui::rgb(0xc0a070))
+ .child("Esc para cancelar · click [Confirmar] para borrar"),
+ ),
+ )
+ .child(
+ div()
+ .id(SharedString::from(format!(
+ "confirm-del-cancel-{}",
+ id_owned
+ )))
+ .px(px(10.))
+ .py(px(4.))
+ .bg(cancel_bg)
+ .text_color(cancel_text)
+ .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
+ .child("Cancelar")
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ this.pending_delete = None;
+ this.toast = Some(SharedString::from(format!(
+ "delete cancelado ({entity_for_cancel})"
+ )));
+ cx.notify();
+ })),
+ )
+ .child(
+ div()
+ .id(SharedString::from(format!(
+ "confirm-del-ok-{}",
+ id_owned
+ )))
+ .px(px(10.))
+ .py(px(4.))
+ .bg(confirm_bg)
+ .text_color(confirm_text)
+ .hover(|d| d.bg(gpui::rgb(0x8a2828)))
+ .child("Confirmar")
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ // Limpiar primero para que un fallo del
+ // commit no deje el banner colgado y el
+ // toast tape el banner.
+ this.pending_delete = None;
+ match this.commit_delete(&entity_for_confirm, id_owned) {
+ Ok(outcome) => {
+ let base = format!(
+ "borrado {entity_for_confirm} {}",
+ short_uuid(&id_owned)
+ );
+ this.toast = Some(append_compact_msg(
+ base,
+ outcome.post_status,
+ ));
+ }
+ Err(e) => {
+ this.toast = Some(SharedString::from(format!(
+ "error borrando: {e}"
+ )));
+ }
+ }
+ cx.notify();
+ })),
+ ),
+ )
+ }
+
+ fn render_sidebar(
+ &self,
+ cx: &mut Context<Self>,
+ panel: gpui::Rgba,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent_active: gpui::Rgba,
+ ) -> gpui::Div {
+ let mut sidebar = div()
+ .w(px(240.))
+ .h_full()
+ .bg(panel)
+ .border_r_1()
+ .border_color(border)
+ .flex()
+ .flex_col();
+
+ sidebar = sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(10.))
+ .text_color(text)
+ .text_size(px(13.))
+ .child("Nakui"),
+ );
+
+ if self.modules.is_empty() {
+ return sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(8.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child("(no hay módulos cargados)"),
+ );
+ }
+
+ // Snapshot del active para evitar borrow del self adentro de la closure.
+ let active_snapshot = self.active.clone();
+
+ for (mod_idx, m) in self.modules.iter().enumerate() {
+ sidebar = sidebar.child(
+ div()
+ .px(px(12.))
+ .py(px(8.))
+ .border_t_1()
+ .border_color(border)
+ .text_color(text)
+ .text_size(px(12.))
+ .child(m.label.clone()),
+ );
+
+ for item in &m.menu {
+ let is_active = active_snapshot
+ .as_ref()
+ .map(|(i, v)| *i == mod_idx && v == &item.view)
+ .unwrap_or(false);
+ let label = item
+ .icon
+ .as_deref()
+ .map(|ic| format!("{ic} {}", item.label))
+ .unwrap_or_else(|| item.label.clone());
+
+ let view_key = item.view.clone();
+ sidebar = sidebar.child(
+ div()
+ .id(SharedString::from(format!(
+ "menu-{}-{}",
+ mod_idx, item.view
+ )))
+ .px(px(20.))
+ .py(px(6.))
+ .text_size(px(12.))
+ .text_color(if is_active { accent_active } else { text_dim })
+ .when(is_active, |d| {
+ d.bg(gpui::rgb(0x232a36)).text_color(text)
+ })
+ .hover(|d| d.bg(gpui::rgb(0x1f2630)))
+ .child(label)
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ this.select_view(mod_idx, view_key.clone(), cx);
+ })),
+ );
+ }
+ }
+ sidebar
+ }
+
+ fn render_main(
+ &mut self,
+ cx: &mut Context<Self>,
+ panel: gpui::Rgba,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ let main = div()
+ .flex_grow()
+ .h_full()
+ .bg(panel)
+ .flex()
+ .flex_col()
+ .p(px(16.));
+
+ let (mod_idx, view_key) = match &self.active {
+ Some(a) => (a.0, a.1.clone()),
+ None => {
+ return main.child(
+ div()
+ .text_color(text_dim)
+ .child("Seleccioná un menú a la izquierda."),
+ );
+ }
+ };
+
+ let view = match self
+ .modules
+ .get(mod_idx)
+ .and_then(|m| m.views.get(&view_key))
+ {
+ Some(v) => v.clone(),
+ None => {
+ return main.child(
+ div()
+ .text_color(text_dim)
+ .child(format!("Vista no encontrada: {view_key}")),
+ );
+ }
+ };
+
+ match view {
+ View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
+ View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
+ }
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn render_list(
+ &self,
+ cx: &mut Context<Self>,
+ mut main: gpui::Div,
+ lv: &ListView,
+ mod_idx: usize,
+ border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ let mut header = div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(12.))
+ .mb(px(12.))
+ .child(
+ div()
+ .text_color(text)
+ .text_size(px(18.))
+ .child(lv.title.clone()),
+ );
+ for (idx, action) in lv.actions.iter().enumerate() {
+ let label = match action {
+ Action::OpenView { label, view } => {
+ label.clone().unwrap_or_else(|| format!("→ {view}"))
+ }
+ Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
+ Action::Morphism { name, .. } => format!("⚡ {name}"),
+ };
+ let action_clone = action.clone();
+ header = header.child(
+ div()
+ .id(SharedString::from(format!(
+ "list-action-{mod_idx}-{idx}"
+ )))
+ .px(px(10.))
+ .py(px(4.))
+ .bg(gpui::rgb(0x232a36))
+ .text_color(accent)
+ .text_size(px(11.))
+ .rounded(px(3.))
+ .hover(|d| d.bg(gpui::rgb(0x2c3540)))
+ .child(label)
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ this.apply_action(action_clone.clone(), cx);
+ })),
+ );
+ }
+ main = main.child(header);
+
+ let rows = self.list_rows(&lv.entity);
+ let total = rows.len();
+
+ let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
+ let mut col_header = div()
+ .flex()
+ .flex_row()
+ .py(px(6.))
+ .border_b_1()
+ .border_color(border)
+ .text_color(text_dim)
+ .text_size(px(11.));
+ for c in &lv.columns {
+ let frac = c.weight / total_weight;
+ col_header = col_header.child(
+ div()
+ .flex_grow()
+ .flex_basis(px(100. * frac))
+ .child(c.label.clone()),
+ );
+ }
+ col_header = col_header
+ .child(div().w(px(80.)).text_color(text_dim).child("id"))
+ .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
+ main = main.child(col_header);
+
+ let entity_name = lv.entity.clone();
+ for (id, value) in &rows {
+ let id_copy = *id;
+ let entity_for_edit = entity_name.clone();
+ let entity_for_delete = entity_name.clone();
+ let mut row = div()
+ .flex()
+ .flex_row()
+ .py(px(6.))
+ .border_b_1()
+ .border_color(gpui::rgb(0x232a36))
+ .text_color(text)
+ .text_size(px(12.));
+ for c in &lv.columns {
+ let frac = c.weight / total_weight;
+ let v = lookup_field(value, &c.field);
+ row = row.child(
+ div()
+ .flex_grow()
+ .flex_basis(px(100. * frac))
+ .child(render_value(v)),
+ );
+ }
+ row = row.child(
+ div()
+ .w(px(80.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child(short_uuid(id)),
+ );
+ // Acciones: ✎ edit + ✕ delete por fila.
+ row = row.child(
+ div()
+ .w(px(70.))
+ .flex()
+ .flex_row()
+ .gap(px(4.))
+ .child(
+ div()
+ .id(SharedString::from(format!(
+ "row-edit-{mod_idx}-{id_copy}"
+ )))
+ .px(px(6.))
+ .text_color(accent)
+ .text_size(px(13.))
+ .hover(|d| d.bg(gpui::rgb(0x2c3540)))
+ .child("✎")
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
+ })),
+ )
+ .child(
+ div()
+ .id(SharedString::from(format!(
+ "row-del-{mod_idx}-{id_copy}"
+ )))
+ .px(px(6.))
+ .text_color(gpui::rgb(0xd07070))
+ .text_size(px(13.))
+ .hover(|d| d.bg(gpui::rgb(0x4a2020)))
+ .child("✕")
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ // Marca para borrar en lugar de borrar
+ // directo: el modal de confirmación se
+ // renderea arriba en `render` y maneja
+ // confirm/cancel.
+ this.pending_delete =
+ Some((entity_for_delete.clone(), id_copy));
+ this.toast = None;
+ cx.notify();
+ })),
+ ),
+ );
+ main = main.child(row);
+ }
+
+ if rows.is_empty() {
+ main = main.child(
+ div()
+ .py(px(12.))
+ .text_color(text_dim)
+ .text_size(px(12.))
+ .child(format!("(sin {})", lv.entity)),
+ );
+ } else {
+ main = main.child(
+ div()
+ .mt(px(8.))
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child(format!("{total} fila(s)")),
+ );
+ }
+
+ main
+ }
+
+ /// Renderea el selector clickable de records existentes para un
+ /// FieldSpec con kind=EntityRef. Lista compacta debajo del input;
+ /// click en una opción setea el TextInput del field con el UUID
+ /// seleccionado. El item del UUID actualmente seleccionado (si
+ /// hay) se resalta con accent color.
+ #[allow(clippy::too_many_arguments)]
+ fn render_entity_ref_selector(
+ &self,
+ cx: &mut Context<Self>,
+ field_name: String,
+ target_entity: String,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ let _ = text;
+ let rows = self.list_rows(&target_entity);
+ let current = self
+ .form_inputs
+ .get(&field_name)
+ .map(|inp| inp.read(&*cx).text().to_string())
+ .unwrap_or_default();
+
+ let mut container = div()
+ .mt(px(4.))
+ .pl(px(8.))
+ .border_l_2()
+ .border_color(gpui::rgb(0x2a2f38))
+ .flex()
+ .flex_col()
+ .gap(px(2.));
+
+ if rows.is_empty() {
+ return container.child(
+ div()
+ .px(px(6.))
+ .py(px(4.))
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(format!(
+ "(sin {target_entity}: creá uno antes para referenciar)"
+ )),
+ );
+ }
+
+ container = container.child(
+ div()
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(format!("Seleccioná un {target_entity}:")),
+ );
+
+ for (id, value) in &rows {
+ let label = human_label_for_record(value, id);
+ let id_str = id.to_string();
+ let is_selected = current == id_str;
+ let field_for_click = field_name.clone();
+ let id_for_click = id_str.clone();
+ container = container.child(
+ div()
+ .id(SharedString::from(format!(
+ "entity-ref-{field_name}-{id_str}"
+ )))
+ .px(px(6.))
+ .py(px(2.))
+ .text_size(px(11.))
+ .text_color(if is_selected { accent } else { text_dim })
+ .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
+ .hover(|d| d.bg(gpui::rgb(0x1f2630)))
+ .child(label)
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ if let Some(input) = this.form_inputs.get(&field_for_click) {
+ input.update(cx, |inp, cx| inp.set_text(id_for_click.clone(), cx));
+ }
+ cx.notify();
+ })),
+ );
+ }
+ container
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn render_form(
+ &self,
+ cx: &mut Context<Self>,
+ mut main: gpui::Div,
+ fv: &FormView,
+ mod_idx: usize,
+ _border: gpui::Rgba,
+ text: gpui::Rgba,
+ text_dim: gpui::Rgba,
+ accent: gpui::Rgba,
+ ) -> gpui::Div {
+ // En modo edit, el título refleja eso para que el user no
+ // se confunda creyendo que hace alta nueva.
+ let title = match self.editing.as_ref() {
+ Some((e, id)) if e == &fv.entity => {
+ format!("Editar {} {}", fv.entity, short_uuid(id))
+ }
+ _ => fv.title.clone(),
+ };
+ main = main.child(
+ div()
+ .text_color(text)
+ .text_size(px(18.))
+ .mb(px(12.))
+ .child(title),
+ );
+ for f in &fv.fields {
+ let label = if f.required {
+ format!("{} *", f.label)
+ } else {
+ f.label.clone()
+ };
+
+ let mut field_box = div().flex().flex_col().mb(px(10.)).child(
+ div()
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .mb(px(2.))
+ .child(label),
+ );
+
+ // Mount del TextInput vivo (creado en select_view).
+ if let Some(input) = self.form_inputs.get(&f.name) {
+ field_box = field_box.child(input.clone());
+ } else {
+ // No debería pasar — select_view crea inputs por cada
+ // field. Fallback display estático por seguridad.
+ field_box = field_box.child(
+ div()
+ .px(px(8.))
+ .py(px(6.))
+ .bg(gpui::rgb(0x171a20))
+ .text_color(text_dim)
+ .child("(input no inicializado)"),
+ );
+ }
+
+ // Para EntityRef, agregamos un selector clickable de
+ // records existentes debajo del TextInput. Click en una
+ // opción setea el TextInput interno con el UUID; el
+ // submit lee de ahí como cualquier otro field.
+ if f.kind == FieldKind::EntityRef {
+ if let Some(target_entity) = &f.ref_entity {
+ field_box = field_box.child(self.render_entity_ref_selector(
+ cx,
+ f.name.clone(),
+ target_entity.clone(),
+ text,
+ text_dim,
+ accent,
+ ));
+ }
+ }
+
+ if let Some(help) = &f.help {
+ field_box = field_box.child(
+ div()
+ .mt(px(2.))
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(help.clone()),
+ );
+ }
+ main = main.child(field_box);
+ }
+
+ let editing_this = matches!(
+ self.editing.as_ref(),
+ Some((e, _)) if e == &fv.entity
+ );
+ let submit_label = match &fv.on_submit {
+ Action::SeedEntity { entity, .. } => {
+ if editing_this {
+ format!("Guardar cambios en {entity}")
+ } else {
+ format!("Crear {entity}")
+ }
+ }
+ Action::Morphism { name, .. } => format!("Ejecutar {name}"),
+ Action::OpenView { label, view } => {
+ label.clone().unwrap_or_else(|| format!("Ir a {view}"))
+ }
+ };
+ let submit_action = fv.on_submit.clone();
+ main = main.child(
+ div().mt(px(12.)).child(
+ div()
+ .id(SharedString::from(format!("form-submit-{mod_idx}")))
+ .px(px(12.))
+ .py(px(6.))
+ .bg(gpui::rgb(0x2c3540))
+ .text_color(accent)
+ .text_size(px(12.))
+ .rounded(px(3.))
+ .hover(|d| d.bg(gpui::rgb(0x3a4555)))
+ .child(submit_label)
+ .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
+ this.apply_action(submit_action.clone(), cx);
+ })),
+ ),
+ );
+
+ main = main.child(
+ div()
+ .mt(px(20.))
+ .text_color(text_dim)
+ .text_size(px(10.))
+ .child(
+ "Tip: click en el campo para enfocar; Enter no envía (todavía), \
+ usá el botón. Backspace borra el último carácter.",
+ ),
+ );
+ main
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ //! Tests del widget. Funciones puras (sin GPUI cx) sólo. Los
+ //! tests de backend impl viven en el binario que provee el
+ //! backend (ej: nakui-ui). Los helpers movidos a
+ //! yahweh-meta-runtime se testean allí.
+ use super::*;
+ use serde_json::json;
+
+ #[test]
+ fn lookup_field_simple_and_nested() {
+ let v = json!({
+ "name": "Acme",
+ "address": { "city": "Bogotá", "country": "CO" }
+ });
+ assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
+ assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
+ assert!(lookup_field(&v, "missing").is_none());
+ assert!(lookup_field(&v, "address.zipcode").is_none());
+ }
+
+ #[test]
+ fn append_compact_msg_handles_both_branches() {
+ // Sin compact: base solo, sin separador.
+ let s = append_compact_msg("creado X".into(), None);
+ assert_eq!(s.as_ref(), "creado X");
+ // Con compact: concatena con "; ".
+ let s = append_compact_msg(
+ "creado X".into(),
+ Some("auto-compact: snapshot @ seq 49".into()),
+ );
+ assert_eq!(s.as_ref(), "creado X; auto-compact: snapshot @ seq 49");
+ }
+
+ #[test]
+ fn format_seed_toast_distinguishes_create_update_no_change() {
+ let id = Uuid::new_v4();
+ let outcome_create = WriteOutcome {
+ id: Some(id),
+ changed: 1,
+ post_status: None,
+ };
+ let toast = format_seed_toast("Customer", false, &outcome_create);
+ assert!(toast.starts_with("creado Customer"));
+
+ let outcome_update = WriteOutcome {
+ id: Some(id),
+ changed: 3,
+ post_status: None,
+ };
+ let toast = format_seed_toast("Customer", true, &outcome_update);
+ assert!(toast.starts_with("actualizado Customer"));
+ assert!(toast.contains("(3 campo(s))"));
+
+ let outcome_no_change = WriteOutcome::no_change(id);
+ let toast = format_seed_toast("Customer", true, &outcome_no_change);
+ assert!(toast.contains("sin cambios"));
+ }
+}