tawasuyu

tawasuyu/brahman

Rust 321TOML 122JSON 14Markdown 114332 Definiciones

feat(nakui-ui): confirmación de delete vía banner modal antes de borrar

El click en `✕` ya no borra inmediatamente: marca el record como
pendiente y abre un banner amber al tope con [Cancelar] / [Confirmar].
Sólo [Confirmar] llama `commit_delete` (la lógica del store/log no
cambia).

Cambios:
- Nuevo `MetaUi.pending_delete: Option<(String, Uuid)>`.
- Click en ✕ → set pending_delete + clear toast.
- Banner renderea como sibling del row sidebar+main en `flex_col`
  raíz; None cuando no hay pending. Texto: "¿Borrar {Entity} {id}?".
- [Cancelar] → toast informativo, sin tocar el store.
- [Confirmar] → limpia pending primero (evita banner colgado en
  caso de error) y llama `commit_delete`.
- `select_view` también limpia pending (record podría no ser visible
  en la nueva view).

22 tests verdes — la lógica del store/log no cambió, sólo el state
machine de UI. La compilación garantiza el wireup de las closures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

fdb9bbe058cbdd4375837a47749739be3a60584f · por Sergio · (hace 5 meses) · padre 46185951d · ver el árbol en este commit

Delta semántico · 6

crates/apps/nakui-ui/src/main.rs

2 archivos · +156 −13

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c77496..102972f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,48 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-09
 
+### feat(nakui-ui): confirmación de delete vía banner modal antes de borrar
+Cierra el primer pending del último round: borrar un record pedía un
+solo click en `✕` y se ejecutaba inmediatamente (irreversible —
+queda en el log como `Morphism { name: "ui.delete_record" }`). Ahora
+hay un paso intermedio: el click marca el record como pendiente y el
+banner amber al tope de la ventana ofrece [Cancelar] o [Confirmar].
+
+Cambios:
+- **Nuevo state `MetaUi.pending_delete: Option<(String, Uuid)>`**.
+  Set en el click del `✕`; limpiado por:
+  - [Cancelar] → toast "delete cancelado (Entity)".
+  - [Confirmar] → llama `commit_delete` (igual que antes) y emite
+    el toast usual.
+  - Navegación a otra view (`select_view`) — el record marcado
+    podría no estar visible en la nueva pantalla.
+- **Click handler de `✕` ya no llama `commit_delete`**: sólo setea
+  `pending_delete` y limpia toast. La acción destructiva ahora vive
+  exclusivamente en el botón [Confirmar] del banner.
+- **Nuevo método `render_confirm_delete_banner`**: devuelve
+  `Option<Div>` (None si no hay pending). Banner amber con el texto
+  `¿Borrar {Entity} {short_uuid}?` + dos botones. Renderea como
+  sibling del row sidebar+main en `flex_col` raíz — no es overlay
+  flotante (GPUI no expone z-index trivialmente), pero la posición
+  fija al tope + color amber lo hacen imposible de ignorar.
+- **Limpieza pre-commit**: `pending_delete = None` se ejecuta antes
+  de `commit_delete`, así un fallo del commit no deja el banner
+  colgado además del toast de error.
+
+22 tests verdes — la lógica del store/log no cambió, sólo el state
+machine de UI. La confirmación es puramente UX/state, no testable
+sin GPUI cx, pero la compilación garantiza wireup correcto de las
+closures.
+
+Pendientes restantes:
+- **Snapshot/compaction** del log para repos grandes (replay full
+  cada startup escala mal).
+- **Edit delta-only** — sólo campos modificados, no todos.
+- **EntityRef validation post-submit** — validar UUID parseable
+  al submit en lugar de al execute del morphism.
+- **Atajo de teclado Esc para Cancelar** — requiere event
+  dispatcher de GPUI, fuera de scope inmediato.
+
 ### feat(nakui-ui): validación estricta de params del morphism vía FieldKind del FieldSpec
 Cierra el último trade-off documentado: `infer_param_value` adivinaba
 el tipo de cada param por la shape del string (i64 → f64 → bool →
diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs
index 7110838..63b39bd 100644
--- a/crates/apps/nakui-ui/src/main.rs
+++ b/crates/apps/nakui-ui/src/main.rs
@@ -92,6 +92,12 @@ struct MetaUi {
     /// en lugar de 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.
@@ -224,6 +230,7 @@ impl MetaUi {
             active,
             form_inputs: BTreeMap::new(),
             editing: None,
+            pending_delete: None,
             toast: initial_toast,
             load_error,
         }
@@ -237,6 +244,9 @@ impl MetaUi {
     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) {
@@ -779,6 +789,7 @@ impl Render for MetaUi {
 
         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.))
@@ -804,6 +815,7 @@ impl Render for MetaUi {
             .size_full()
             .bg(bg)
             .when_some(error_banner, |d, b| d.child(b))
+            .when_some(confirm_banner, |d, b| d.child(b))
             .child(
                 div()
                     .flex()
@@ -817,6 +829,101 @@ impl Render for MetaUi {
 }
 
 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()
+                        .child(format!("¿Borrar {entity_owned} {id_short}?")),
+                )
+                .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(()) => {
+                                    this.toast = Some(SharedString::from(format!(
+                                        "borrado {entity_for_confirm} {}",
+                                        short_uuid(&id_owned)
+                                    )));
+                                }
+                                Err(e) => {
+                                    this.toast = Some(SharedString::from(format!(
+                                        "error borrando: {e}"
+                                    )));
+                                }
+                            }
+                            cx.notify();
+                        })),
+                ),
+        )
+    }
+
     fn render_sidebar(
         &self,
         cx: &mut Context<Self>,
@@ -1096,19 +1203,13 @@ impl MetaUi {
                             .hover(|d| d.bg(gpui::rgb(0x4a2020)))
                             .child("✕")
                             .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
-                                match this.commit_delete(&entity_for_delete, id_copy) {
-                                    Ok(()) => {
-                                        this.toast = Some(SharedString::from(format!(
-                                            "borrado {entity_for_delete} {}",
-                                            short_uuid(&id_copy)
-                                        )));
-                                    }
-                                    Err(e) => {
-                                        this.toast = Some(SharedString::from(format!(
-                                            "error borrando: {e}"
-                                        )));
-                                    }
-                                }
+                                // 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();
                             })),
                     ),