feat(tahuantinsuyu): modal "Guardar como…" real para cartas libres (F3)

Reemplaza el `save_free_chart_quick` MVP de la fase A por un
modal completo que el usuario controla:

Tree:
- Nuevo `Modal::SaveFreeChart { source_id, name, new_contact_name,
  selected_contact, all_contacts, error }`.
- `open_save_free_chart_modal` abre el modal pre-poblando `name`
  con el label de la carta libre y `selected_contact` con el
  primer contacto existente (o `None` = nuevo contacto si no
  hay ninguno).
- `gather_all_contacts` recorre la jerarquía recursivamente
  devolviendo `(ContactId, "Grupo / Subgrupo / Contacto")` —
  el usuario ve la ruta completa, no solo el nombre.
- `render_save_free_chart` pinta:
  * Input "Nombre" pre-cargado
  * Lista de contactos como botones radio (● / ○) + opción
    "Nuevo contacto…" al final
  * Si "Nuevo contacto…" seleccionado, aparece input
    "Nombre del contacto nuevo"
  * Botones Cancelar / Guardar
- `set_save_modal_contact` alterna el radio sin recrear inputs.
- Validaciones: nombre de carta no vacío; si `selected_contact`
  es `None`, exigir `new_contact_name` no vacío. Errores se
  muestran en una pill destructiva dentro del modal.
- Submit emite nuevo evento `TreeEvent::FreeChartSaveConfirmed
  { source_id, chart_name, contact, new_contact_name }`.

Shell:
- `persist_free_chart` resuelve el contacto destino (existente
  o crea uno nuevo), llama `store.create_chart`, y al éxito
  remueve la carta libre del mapa (salvo `sky-now`, que es
  persistente). Si la carta libre estaba seleccionada, vuelve
  al Cielo. Refresca opciones del picker para que el dropdown
  ChartPicker incluya la carta recién guardada.
- El handler `SaveFreeChartRequested` queda como hook vacío;
  el menú del tree abre el modal directamente con `window`.

10 tests verdes (no se afectaron los paths probados).

Próximo: F2 (editor inline de fecha/lugar/hora de la carta
libre) y F4 (botón "Guardar como…" en cada módulo overlay
con sufijo automático).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 21:36:30 +00:00
parent 72da2934e8
commit a83b0396ce
2 changed files with 448 additions and 32 deletions
@@ -70,6 +70,15 @@ pub enum TreeEvent {
/// Borrar una carta libre del mapa del shell. Si es `sky-now`,
/// el shell ignora (no se puede borrar el Cielo).
DeleteFreeChartRequested(FreeChartId),
/// Submit del modal "Guardar como" — el shell crea/usa el
/// contacto y persiste la carta. Si `contact` es `None`, el
/// shell crea uno nuevo con `new_contact_name`.
FreeChartSaveConfirmed {
source_id: FreeChartId,
chart_name: String,
contact: Option<ContactId>,
new_contact_name: Option<String>,
},
}
// =====================================================================
@@ -148,6 +157,25 @@ enum Modal {
form: ChartForm,
error: Option<SharedString>,
},
/// Guardar una carta libre como `Chart` persistido. El usuario
/// elige nombre + contacto destino (existente de la lista o
/// uno nuevo creado al vuelo). El shell escucha
/// `TreeEvent::FreeChartSaveConfirmed` y materializa.
SaveFreeChart {
source_id: FreeChartId,
name: Entity<TextInput>,
/// Nombre del contacto NUEVO (solo aplica si
/// `selected_contact == None`). Vacío para reusar uno
/// existente.
new_contact_name: Entity<TextInput>,
/// `Some(id)` = usar contacto existente; `None` = crear
/// contacto nuevo con `new_contact_name`.
selected_contact: Option<ContactId>,
/// Snapshot de contactos visibles al usuario en el momento
/// de abrir el modal. Incluye contact id + label (nombre).
all_contacts: Vec<(ContactId, String)>,
error: Option<SharedString>,
},
}
struct ChartForm {
@@ -845,6 +873,101 @@ impl TahuantinsuyuTree {
self.close_menu(cx);
}
/// Abre el modal "Guardar como" para una carta libre. Pre-puebla
/// el `name` con el label actual de la entry. La lista de
/// contactos es un snapshot recursivo de toda la jerarquía
/// (no solo el nivel raíz). El usuario elige uno existente o
/// deja en "Nuevo contacto" para que se cree uno al confirmar.
/// Cambia `selected_contact` del modal `SaveFreeChart` activo
/// sin recrear los inputs. Permite alternar entre los botones
/// radio "contacto existente" y "Nuevo contacto…".
fn set_save_modal_contact(
&mut self,
new_selection: Option<ContactId>,
expected_source: &FreeChartId,
cx: &mut Context<'_, Self>,
) {
if let Some(Modal::SaveFreeChart {
source_id,
selected_contact,
..
}) = self.modal.as_mut()
{
if source_id == expected_source {
*selected_contact = new_selection;
cx.notify();
}
}
}
fn open_save_free_chart_modal(
&mut self,
source_id: FreeChartId,
window: &mut Window,
cx: &mut Context<'_, Self>,
) {
let default_label = self
.free_charts
.iter()
.find(|e| e.id == source_id)
.map(|e| e.label.clone())
.unwrap_or_else(|| "Carta libre".into());
let all_contacts = self.gather_all_contacts();
let name_input = self.make_input("Nombre de la carta", &default_label, window, cx);
let new_contact_input =
self.make_input("Nombre del contacto nuevo", "", window, cx);
// Default: primer contacto existente si lo hay; sino "Nuevo
// contacto" (None). Eso minimiza clicks cuando el usuario ya
// tiene contactos cargados.
let selected_contact = all_contacts.first().map(|(id, _)| *id);
self.modal = Some(Modal::SaveFreeChart {
source_id,
name: name_input,
new_contact_name: new_contact_input,
selected_contact,
all_contacts,
error: None,
});
self.close_menu(cx);
}
/// Snapshot recursivo de todos los contactos del árbol —
/// `(id, label)`. Usado por el modal "Guardar como" para
/// listar destinos. Las cartas se cuelgan del contacto que el
/// usuario elija.
fn gather_all_contacts(&self) -> Vec<(ContactId, String)> {
fn walk(
store: &Store,
parent: Option<GroupId>,
prefix: &str,
out: &mut Vec<(ContactId, String)>,
) {
if let Ok(contacts) = store.list_contacts(parent) {
for c in contacts {
let label = if prefix.is_empty() {
c.name.clone()
} else {
format!("{}{}", prefix, c.name)
};
out.push((c.id, label));
}
}
if let Ok(groups) = store.list_groups(parent) {
for g in groups {
let new_prefix = if prefix.is_empty() {
format!("{} / ", g.name)
} else {
format!("{}{} / ", prefix, g.name)
};
walk(store, Some(g.id), &new_prefix, out);
}
}
}
let mut out = Vec::new();
walk(&self.store, None, "", &mut out);
out
}
fn open_create_chart(
&mut self,
contact: ContactId,
@@ -1107,6 +1230,62 @@ impl TahuantinsuyuTree {
}
}
}
Modal::SaveFreeChart {
source_id,
name,
new_contact_name,
selected_contact,
all_contacts,
error: _,
} => {
let _ = value;
let chart_name = name.read(cx).text().to_string();
let chart_name = chart_name.trim();
if chart_name.is_empty() {
self.modal = Some(Modal::SaveFreeChart {
source_id,
name,
new_contact_name,
selected_contact,
all_contacts,
error: Some("El nombre de la carta no puede estar vacío".into()),
});
cx.notify();
return;
}
let new_contact = if selected_contact.is_none() {
let v = new_contact_name.read(cx).text().to_string();
let v = v.trim();
if v.is_empty() {
self.modal = Some(Modal::SaveFreeChart {
source_id,
name,
new_contact_name,
selected_contact,
all_contacts,
error: Some(
"Elegí un contacto existente o escribí un nombre para el nuevo"
.into(),
),
});
cx.notify();
return;
}
Some(v.to_string())
} else {
None
};
cx.emit(TreeEvent::FreeChartSaveConfirmed {
source_id,
chart_name: chart_name.to_string(),
contact: selected_contact,
new_contact_name: new_contact,
});
drop(name);
drop(new_contact_name);
self.modal = None;
cx.notify();
}
}
}
@@ -1441,9 +1620,8 @@ impl TahuantinsuyuTree {
let fid_save = fid.clone();
items = items.child(
menu_item("tts-menu-save-free", "Guardar como…", theme).on_click(
cx.listener(move |this, _: &ClickEvent, _w, cx| {
cx.emit(TreeEvent::SaveFreeChartRequested(fid_save.clone()));
this.close_menu(cx);
cx.listener(move |this, _: &ClickEvent, w, cx| {
this.open_save_free_chart_modal(fid_save.clone(), w, cx);
}),
),
);
@@ -1530,6 +1708,23 @@ impl TahuantinsuyuTree {
&self.city_atlas,
cx,
),
Modal::SaveFreeChart {
source_id,
name,
new_contact_name,
selected_contact,
all_contacts,
error,
} => render_save_free_chart(
theme,
source_id.clone(),
name.clone(),
new_contact_name.clone(),
*selected_contact,
all_contacts,
error.clone(),
cx,
),
};
div()
@@ -1603,6 +1798,184 @@ fn modal_box(
)
}
/// Modal "Guardar como" para una carta libre. Layout:
///
/// ```text
/// [Nombre de la carta] — TextInput pre-poblado con label
/// Contacto destino:
/// ○ Contacto A
/// ○ Contacto B
/// ● Nuevo contacto… → [Nombre del contacto] TextInput
/// [Cancelar] [Guardar]
/// ```
///
/// El submit emite `TreeEvent::FreeChartSaveConfirmed` que el shell
/// materializa contra la store.
#[allow(clippy::too_many_arguments)]
fn render_save_free_chart(
theme: &Theme,
source_id: FreeChartId,
name: Entity<TextInput>,
new_contact_name: Entity<TextInput>,
selected_contact: Option<ContactId>,
all_contacts: &[(ContactId, String)],
error: Option<SharedString>,
cx: &mut Context<'_, TahuantinsuyuTree>,
) -> gpui::Div {
let title_row = div()
.text_size(px(14.0))
.text_color(theme.fg_text)
.child(SharedString::from("Guardar carta libre"));
let label_row =
|label: &'static str| -> gpui::Div {
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child(label)
};
let name_block = div()
.flex()
.flex_col()
.gap(px(2.0))
.child(label_row("Nombre"))
.child(name.clone());
// Lista de contactos como botones radio.
let mut contact_list = div().flex().flex_col().gap(px(4.0));
for (cid, label) in all_contacts.iter() {
let selected = selected_contact == Some(*cid);
let cid_for_click = *cid;
let source_for_click = source_id.clone();
let row_id: SharedString =
SharedString::from(format!("tts-save-pick-{}", cid));
let bullet = if selected { "" } else { "" };
let row = div()
.id(gpui::ElementId::from(row_id))
.flex()
.flex_row()
.gap(px(8.0))
.px(px(6.0))
.py(px(3.0))
.rounded(px(4.0))
.text_size(px(11.0))
.text_color(theme.fg_text)
.hover(|s| s.bg(theme.bg_row_hover))
.child(bullet.to_string())
.child(label.clone())
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.set_save_modal_contact(Some(cid_for_click), &source_for_click, cx);
}));
contact_list = contact_list.child(row);
}
// Opción "Nuevo contacto…" — bullet activo si selected_contact==None.
let new_selected = selected_contact.is_none();
let new_bullet = if new_selected { "" } else { "" };
let source_for_new = source_id.clone();
contact_list = contact_list.child(
div()
.id(gpui::ElementId::from(SharedString::from(
"tts-save-pick-new",
)))
.flex()
.flex_row()
.gap(px(8.0))
.px(px(6.0))
.py(px(3.0))
.rounded(px(4.0))
.text_size(px(11.0))
.text_color(theme.fg_text)
.hover(|s| s.bg(theme.bg_row_hover))
.child(new_bullet.to_string())
.child("Nuevo contacto…")
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.set_save_modal_contact(None, &source_for_new, cx);
})),
);
let mut contacts_block = div()
.flex()
.flex_col()
.gap(px(2.0))
.child(label_row("Contacto destino"))
.child(contact_list);
// Si "Nuevo contacto" está activo, mostrar el TextInput debajo.
if new_selected {
contacts_block = contacts_block.child(
div()
.pt(px(4.0))
.flex()
.flex_col()
.gap(px(2.0))
.child(label_row("Nombre del contacto nuevo"))
.child(new_contact_name.clone()),
);
}
let save_btn = div()
.id("tts-save-free-confirm")
.px(px(14.0))
.py(px(8.0))
.rounded(px(6.0))
.bg(theme.bg_button())
.hover(|s| s.bg(theme.bg_button_hover()))
.text_size(px(12.0))
.text_color(theme.fg_text)
.child("Guardar")
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.submit_modal(String::new(), cx);
}));
let cancel_btn = div()
.id("tts-save-free-cancel")
.px(px(14.0))
.py(px(8.0))
.rounded(px(6.0))
.bg(theme.bg_panel.clone())
.hover(|s| s.bg(theme.bg_row_hover))
.text_size(px(12.0))
.text_color(theme.fg_muted)
.child("Cancelar")
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.close_modal(cx);
}));
let mut body = div()
.min_w(px(420.0))
.p(px(18.0))
.flex()
.flex_col()
.gap(px(12.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(theme.border_strong)
.rounded(px(8.0))
.child(title_row)
.child(name_block)
.child(contacts_block);
if let Some(err) = error {
body = body.child(
div()
.px(px(10.0))
.py(px(6.0))
.rounded(px(4.0))
.bg(theme.bg_destructive_hover())
.text_size(px(11.0))
.text_color(theme.accent_destructive())
.child(err),
);
}
body = body.child(
div()
.flex()
.flex_row()
.gap(px(8.0))
.justify_end()
.child(cancel_btn)
.child(save_btn),
);
body
}
fn render_chart_form(
theme: &Theme,
title: &str,