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
+190 -55
View File
@@ -1873,6 +1873,78 @@ void views_table(AppState& app) {
// Table node UI fase 2 (issue 0011) — ventana expandida + import
// ----------------------------------------------------------------------------
AppState::NodeGroupsWindowState*
views_node_groups_open(AppState& app,
const std::string& container_id,
NodeGroupsKind kind,
const char* ops_db)
{
if (container_id.empty()) return nullptr;
auto it = app.node_groups_windows.find(container_id);
if (it != app.node_groups_windows.end()) {
// Ya existe — no recargar metadata, solo pedir focus. El kind se
// respeta tal como estaba (mover entre kinds para el mismo id no
// tiene sentido en la UI actual).
it->second.open = true;
it->second.focus_request = true;
return &it->second;
}
auto& w = app.node_groups_windows[container_id];
w.kind = kind;
w.open = true;
w.focus_request = true;
w.page_dirty = true;
w.offset = 0;
w.page.clear();
w.total_rows = 0;
w.last_error.clear();
// Pre-popular meta segun el kind. Para kind=Group, las columnas son
// fijas y conocidas — no hace falta tocar BD para descubrirlas, y
// tampoco hay un nodo type='Table' que leer.
w.meta = NodeGroupsMeta{};
w.meta.entity_id = container_id;
if (kind == NodeGroupsKind::Group) {
w.meta.columns = {"id", "name", "type_ref", "status", "updated_at"};
w.meta.id_column = "id";
w.meta.label_column = "name";
// Best effort: leer el name del Group desde operations.db para que
// el titulo de la ventana sea informativo. Si falla no bloquea.
if (ops_db && *ops_db) {
sqlite3* db = nullptr;
if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) {
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT name FROM entities WHERE id = ?",
-1, &st, nullptr) == SQLITE_OK) {
sqlite3_bind_text(st, 1, container_id.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
const unsigned char* p = sqlite3_column_text(st, 0);
if (p) w.meta.name = (const char*)p;
}
sqlite3_finalize(st);
}
sqlite3_close(db);
}
}
} else {
// kind=Table: cargar metadata real del nodo Table-typed. El path
// tipico para entries creadas por views_node_groups_windows_sync
// ya hace esto, pero si llaman a views_node_groups_open directo
// queremos comportamiento equivalente.
if (ops_db && *ops_db) {
NodeGroupsMeta meta;
if (node_groups_get_metadata(ops_db, container_id.c_str(), &meta)) {
w.meta = std::move(meta);
}
}
}
return &w;
}
void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
if (!app.graph || !ops_db) return;
GraphData& g = *app.graph;
@@ -1914,8 +1986,11 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
sqlite3_finalize(st);
sqlite3_close(db);
// Quitar las que ya no estan expanded.
// Quitar las que ya no estan expanded — pero solo las kind=Table.
// Las kind=Group viven en operations.db con su propia condicion de
// existencia (entity con type_ref='Group') y no deben tocarse aqui.
for (auto it = app.node_groups_windows.begin(); it != app.node_groups_windows.end(); ) {
if (it->second.kind == NodeGroupsKind::Group) { ++it; continue; }
if (live.find(it->first) == live.end()) it = app.node_groups_windows.erase(it);
else ++it;
}
@@ -1927,6 +2002,7 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
for (auto& kv : live) {
auto& w = app.node_groups_windows[kv.first];
bool was_present = !w.meta.entity_id.empty();
w.kind = NodeGroupsKind::Table; // expanded -> siempre Table
w.meta = std::move(kv.second);
w.open = true;
w.page_dirty = true;
@@ -1947,101 +2023,160 @@ void views_node_groups_window(AppState& app) {
NodeGroupsMeta& m = kv.second.meta;
AppState::NodeGroupsWindowState& w = kv.second;
const bool is_group = (w.kind == NodeGroupsKind::Group);
char title[160];
std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s",
m.name.empty() ? "(unnamed)" : m.name.c_str(),
m.entity_id.c_str());
if (is_group) {
std::snprintf(title, sizeof(title), TI_TABLE " Group: %s##te_%s",
m.name.empty() ? "(unnamed)" : m.name.c_str(),
m.entity_id.c_str());
} else {
std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s",
m.name.empty() ? "(unnamed)" : m.name.c_str(),
m.entity_id.c_str());
}
ImGui::SetNextWindowSize(ImVec2(640, 460), ImGuiCond_FirstUseEver);
if (w.focus_request) {
// El render lo consume 0036c — por ahora simplemente lo limpiamos
// tras un frame para que no se quede pegado. Cuando 0036c llegue
// anadira ImGui::SetNextWindowFocus() aqui.
w.focus_request = false;
}
if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; }
// Header de info
ImGui::TextDisabled("%s · %s · %lld rows",
m.duckdb_path.c_str(), m.table_name.c_str(),
(long long)w.total_rows);
// Header de info (varia por kind)
if (is_group) {
ImGui::TextDisabled("group_id=%s · %lld rows",
m.entity_id.c_str(),
(long long)w.total_rows);
} else {
ImGui::TextDisabled("%s · %s · %lld rows",
m.duckdb_path.c_str(), m.table_name.c_str(),
(long long)w.total_rows);
}
if (!w.last_error.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
"ERROR: %s", w.last_error.c_str());
}
ImGui::Separator();
// Tabla
const int col_count = (int)m.columns.size() + 2; // id + columns... + promoted
// Tabla — layout depende del kind:
// Table: [id_column] + columns[] + [promoted] (col_count = N+2)
// Group: columns[] (id, name, type_ref, status, updated_at)
// (col_count = N)
const int col_count = is_group
? (int)m.columns.size()
: (int)m.columns.size() + 2;
ImGuiTableFlags tflags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
ImGuiTableFlags_SizingStretchProp;
if (ImGui::BeginTable("##te_rows", col_count, tflags,
if (col_count > 0 && ImGui::BeginTable("##te_rows", col_count, tflags,
ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) {
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
ImGuiTableColumnFlags_WidthFixed, 100.0f);
for (const auto& c : m.columns) {
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
if (is_group) {
for (size_t i = 0; i < m.columns.size(); ++i) {
bool is_id = (i == 0);
ImGui::TableSetupColumn(m.columns[i].c_str(),
is_id ? ImGuiTableColumnFlags_WidthFixed
: ImGuiTableColumnFlags_WidthStretch,
is_id ? 160.0f : 0.0f);
}
} else {
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
ImGuiTableColumnFlags_WidthFixed, 100.0f);
for (const auto& c : m.columns) {
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
}
ImGui::TableSetupColumn("promoted",
ImGuiTableColumnFlags_WidthFixed, 80.0f);
}
ImGui::TableSetupColumn("promoted",
ImGuiTableColumnFlags_WidthFixed, 80.0f);
ImGui::TableHeadersRow();
// Decidir paginacion por scroll: pedimos siempre 200 filas a
// partir de offset; si el usuario llega cerca del final,
// avanzamos offset.
const int64_t page_size = 200;
for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) {
const NodeGroupsRow& row = w.page[i];
ImGui::TableNextRow();
ImGui::PushID((int)(w.offset + i));
ImGui::TableSetColumnIndex(0);
bool is_promoted = !row.promoted_entity_id.empty();
// Selectable spanning para que el doble-click y el right-click
// funcionen sobre toda la fila, no solo el texto del id.
// funcionen sobre toda la fila, no solo el texto.
ImGuiSelectableFlags sf = ImGuiSelectableFlags_SpanAllColumns
| ImGuiSelectableFlags_AllowDoubleClick;
ImGui::Selectable(row.id.c_str(), false, sf);
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
if (is_promoted) {
if (is_group) {
// En kind=Group la fila YA es una entidad real del grafo.
// Doble click → focus inspector. Right click → focus.
// (El boton "Promote" no aplica — 0036d hace eso para
// contextos donde tenga sentido.)
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
app.want_focus_entity = true;
app.focus_entity_id = row.promoted_entity_id;
} else {
app.want_promote_row = true;
app.promote_table_id = m.entity_id;
app.promote_row_id = row.id;
app.focus_entity_id = row.id;
}
}
if (ImGui::BeginPopupContextItem()) {
if (is_promoted) {
if (ImGui::BeginPopupContextItem()) {
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
app.want_focus_entity = true;
app.focus_entity_id = row.id;
}
ImGui::EndPopup();
}
// Render de las columnas (la 0 ya tiene el Selectable;
// el texto del id se ve en el propio Selectable).
for (size_t c = 1; c < m.columns.size(); ++c) {
ImGui::TableSetColumnIndex((int)c);
if (c < row.values.size())
ImGui::TextUnformatted(row.values[c].c_str());
}
} else {
// kind=Table — comportamiento original (DuckDB-backed).
bool is_promoted = !row.promoted_entity_id.empty();
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
if (is_promoted) {
app.want_focus_entity = true;
app.focus_entity_id = row.promoted_entity_id;
}
if (ImGui::MenuItem(TI_X " Demote (delete entity)")) {
app.want_demote_entity = true;
app.demote_entity_id = row.promoted_entity_id;
}
} else {
if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) {
} else {
app.want_promote_row = true;
app.promote_table_id = m.entity_id;
app.promote_row_id = row.id;
}
}
ImGui::EndPopup();
}
if (ImGui::BeginPopupContextItem()) {
if (is_promoted) {
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
app.want_focus_entity = true;
app.focus_entity_id = row.promoted_entity_id;
}
if (ImGui::MenuItem(TI_X " Demote (delete entity)")) {
app.want_demote_entity = true;
app.demote_entity_id = row.promoted_entity_id;
}
} else {
if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) {
app.want_promote_row = true;
app.promote_table_id = m.entity_id;
app.promote_row_id = row.id;
}
}
ImGui::EndPopup();
}
for (size_t c = 0; c < m.columns.size(); ++c) {
ImGui::TableSetColumnIndex(1 + (int)c);
if (c < row.values.size())
ImGui::TextUnformatted(row.values[c].c_str());
}
ImGui::TableSetColumnIndex(col_count - 1);
if (is_promoted) {
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
ImGui::TextUnformatted("yes");
ImGui::PopStyleColor();
} else {
ImGui::TextDisabled("-");
for (size_t c = 0; c < m.columns.size(); ++c) {
ImGui::TableSetColumnIndex(1 + (int)c);
if (c < row.values.size())
ImGui::TextUnformatted(row.values[c].c_str());
}
ImGui::TableSetColumnIndex(col_count - 1);
if (is_promoted) {
ImGui::PushStyleColor(ImGuiCol_Text,
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
ImGui::TextUnformatted("yes");
ImGui::PopStyleColor();
} else {
ImGui::TextDisabled("-");
}
}
ImGui::PopID();
}