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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user