feat(0036b): NodeGroups admite kind=Group + loader entities

NodeGroupsWindowState gana un discriminador `kind` (Table | Group) y
un flag `focus_request` (lo consumira 0036c). Por defecto Table, asi
que el flujo historico (DuckDB rows tras expand de un nodo Table) no
cambia.

kind=Group lee directamente operations.db consultando
`entities WHERE group_id = container_id` con columnas fijas
(id, name, type_ref, status, updated_at) ordenadas por updated_at DESC.
Los nuevos loaders viven en node_groups.cpp:

  - node_groups_count_for_group  -> SELECT count(*) ...
  - node_groups_page_for_group   -> SELECT id,name,type_ref,status,
                                     updated_at ... LIMIT ? OFFSET ?

Para columnas, opcion (A) del issue: pre-popular meta.columns con la
lista fija al abrir kind=Group, asi el render se mantiene generico.
NodeGroupsRow.values guarda los 5 campos en ese orden y row.id es la
key natural (= entity_id de la fila — al ser ya entidad, no hace falta
promocionarla).

Render en views.cpp ramifica por kind:

  - Table: layout original [id_col + columns + promoted] con doble
    click -> promote/focus.
  - Group: layout [columns fijas] sin promoted. Doble click sobre la
    fila ya pone want_focus_entity = id (los flujos posteriores 0036c-e
    afinan UX). Right click ofrece "Focus in Inspector".

main.cpp dispatcha por kind al refrescar paginas y, al cerrar via X,
solo llama a node_groups_set_expanded para kind=Table (Group no usa
ese flag).

views_node_groups_windows_sync se hace kind-aware: solo reconcilia
entries kind=Table contra el set de Tables expandidas; no toca las
entries kind=Group (las gestiona views_node_groups_open).

Nueva API publica:

  views_node_groups_open(app, container_id, kind, ops_db)

Crea o reusa la entry, setea focus_request=true y para kind=Group
pre-popula meta.columns + intenta leer `name` del Group para el
titulo. Sin caller todavia — la consume 0036c.

Tests:
  - tests/test_node_groups_loader.py (6 tests) verifica el contrato
    SQL via gx-cli. Nuevo subcomando `gx-cli group page <id>` espejea
    el loader C++ exactamente (mismo SQL); tambien expuesto como tool
    MCP `group_page` para que Echo pueda inspeccionar Groups.

Resultado:
  - WSL: 89 -> 95 passed
  - Windows: 78+11 -> 84+11 passed
  - Build C++ Windows limpio, sin warnings nuevos.
  - Regresion kind=Table: comportamiento identico (mismo render,
    mismo loader DuckDB).

Refs: issues/0036b-kind-discriminator-and-group-loader.md
This commit is contained in:
2026-05-04 00:52:25 +02:00
parent 2a783187a3
commit d6e13fddc3
8 changed files with 613 additions and 65 deletions
+37 -6
View File
@@ -15,6 +15,11 @@ struct GraphViewportState;
namespace ge {
// Discriminador de la NodeGroups window (issue 0036b). Una window puede
// estar respaldada por una tabla DuckDB (kind=Table) o por una agrupacion
// de entidades en operations.db via `entities.group_id` (kind=Group).
enum class NodeGroupsKind { Table, Group };
// Estado compartido entre las vistas y el bucle render. Pasado por puntero
// desde main.cpp.
struct AppState {
@@ -160,19 +165,26 @@ struct AppState {
// Refrescado tras load_input y tras mutaciones que afecten a Tables.
std::unordered_map<uint64_t, int64_t> node_groups_counts;
// ---- NodeGroups window (issue 0011, renombrado en 0036a) --------------
// Estado runtime por ventana de NodeGroups (un Table-typed expandido).
// Una entrada por entity_id de Table que el usuario haya expandido. La
// ventana se cierra cuando set_expanded(false) — ya sea desde context
// menu o cerrando la ImGui window (que pone el flag a false
// automaticamente).
// ---- NodeGroups window (issue 0011, renombrado en 0036a, kind en 0036b) -
// Estado runtime por ventana de NodeGroups. Hay dos kinds (issue 0036b):
// - Table: respaldada por DuckDB (el comportamiento original — un nodo
// `Table` del grafo que apunta a un .duckdb + table_name).
// - Group: respaldada por la propia operations.db. Lista las entidades
// hijas (`entities.group_id = container_id`) con columnas fijas
// id/name/type_ref/status/updated_at.
//
// Una entrada por container_id (entity_id del nodo contenedor — Table o
// Group). La ventana se cierra al pulsar la X de ImGui o, en kind=Table,
// al hacer set_expanded(false) desde el menu contextual.
struct NodeGroupsWindowState {
NodeGroupsKind kind = NodeGroupsKind::Table; // default compat 0036a
NodeGroupsMeta meta; // refrescada cada vez que entity cambia
int64_t total_rows = 0;
int64_t offset = 0;
std::vector<NodeGroupsRow> page;
bool page_dirty = true;
bool open = true; // bound a ImGui::Begin
bool focus_request = false; // 0036c: pedir SetWindowFocus
std::string last_error; // ultimo error de query (vacio = OK)
};
std::unordered_map<std::string, NodeGroupsWindowState> node_groups_windows;
@@ -356,6 +368,25 @@ bool views_import_dataset_modal(AppState& app);
// entradas para nuevos expanded y borra las que ya no aplican.
void views_node_groups_windows_sync(AppState& app, const char* ops_db);
// Crea o reusa una entrada en `app.node_groups_windows[container_id]` y la
// marca con el `kind` indicado. Setea `focus_request = true` para que el
// render pueda llamar a ImGui::SetWindowFocus (lo consume 0036c). Si la
// entry ya existe se respeta su kind anterior y solo se setea
// focus_request — no recarga ni resetea offset. Si es nueva, llama a
// `node_groups_load_metadata` para popular `meta` (en kind=Group eso pre-
// puebla las columnas fijas; en kind=Table lee la metadata del Table-typed
// node de operations.db).
//
// `ops_db` es el path de operations.db. Si esta vacio, no se carga
// metadata pero la entry se crea de todos modos (caller puede rellenar
// despues). Devuelve puntero a la entry — nunca nullptr salvo
// container_id vacio.
AppState::NodeGroupsWindowState*
views_node_groups_open(AppState& app,
const std::string& container_id,
NodeGroupsKind kind,
const char* ops_db);
// ---- Table node overlay (issue 0010) ------------------------------------
// Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo