diff --git a/gx-cli b/gx-cli index a1e43f2..14b077b 100755 --- a/gx-cli +++ b/gx-cli @@ -560,6 +560,38 @@ def cmd_table_page(args) -> None: "offset": args.offset, "limit": args.limit, "rows": rows}) +# ---------------------------------------------------------------------------- +# group ops (issue 0036b) — espejo Python del loader C++ kind=Group +# ---------------------------------------------------------------------------- + +def cmd_group_page(args) -> None: + """Lista entidades hijas de un Group (entities.group_id = ?). + + Espejea exactamente la query del loader C++ + `node_groups_page_for_group` para que los tests pytest verifiquen + el contrato SQL (mismo orden de filas, mismas columnas) sin depender + del binario. Util tambien como tool MCP para que el agente Echo + inspeccione el contenido de un Group sin abrir la app. + """ + cn = _connect(_ops_db(), readonly=True) + total = cn.execute( + "SELECT count(*) FROM entities WHERE group_id = ?", + (args.container_id,), + ).fetchone()[0] + limit = max(1, min(int(args.limit), 5000)) + offset = max(0, int(args.offset)) + cur = cn.execute( + "SELECT id, name, type_ref, status, updated_at " + "FROM entities WHERE group_id = ? " + "ORDER BY updated_at DESC LIMIT ? OFFSET ?", + (args.container_id, limit, offset), + ) + rows = [dict(r) for r in cur.fetchall()] + cn.close() + _emit({"ok": True, "container": args.container_id, "total": total, + "offset": offset, "limit": limit, "rows": rows}) + + # ---------------------------------------------------------------------------- # enricher ops # ---------------------------------------------------------------------------- @@ -841,6 +873,13 @@ MCP_TOOLS = [ "description": "Borra la entidad promovida. La fila DuckDB queda intacta.", "inputSchema": {"type": "object", "properties": { "id": {"type": "string"}}, "required": ["id"]}}, + {"name": "group_page", + "description": "Lista entidades hijas de un Group (entities.group_id = container_id). Espejea el loader C++ de NodeGroups kind=Group.", + "inputSchema": {"type": "object", "properties": { + "container_id": {"type": "string"}, + "offset": {"type": "integer", "default": 0, "minimum": 0}, + "limit": {"type": "integer", "default": 200, "minimum": 1, "maximum": 5000}}, + "required": ["container_id"]}}, {"name": "enricher_list", "description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.", "inputSchema": {"type": "object", "properties": { @@ -902,6 +941,7 @@ MCP_DISPATCH = { "table_page": (cmd_table_page, {"offset": 0, "limit": 50}), "table_promote": (cmd_table_promote, {}), "table_demote": (cmd_table_demote, {}), + "group_page": (cmd_group_page, {"offset": 0, "limit": 200}), "enricher_list": (cmd_enricher_list, {"type": None}), "enricher_run": (cmd_enricher_run, {"node": None, "params": None}), "query": (cmd_query, {"limit": 100}), @@ -1093,6 +1133,15 @@ def main() -> None: sp.add_argument("--limit", type=int, default=50) sp.set_defaults(fn=cmd_table_page) + # group (issue 0036b) + g = sub.add_parser("group").add_subparsers(dest="op", required=True) + sp = g.add_parser("page", + help="Lista entidades hijas de un Group (group_id=?)") + sp.add_argument("container_id") + sp.add_argument("--offset", type=int, default=0) + sp.add_argument("--limit", type=int, default=200) + sp.set_defaults(fn=cmd_group_page) + # enricher e = sub.add_parser("enricher").add_subparsers(dest="op", required=True) sp = e.add_parser("list") diff --git a/issues/0036b-kind-discriminator-and-group-loader.md b/issues/0036b-kind-discriminator-and-group-loader.md new file mode 100644 index 0000000..eb9b389 --- /dev/null +++ b/issues/0036b-kind-discriminator-and-group-loader.md @@ -0,0 +1,83 @@ +--- +id: 0036b +title: NodeGroups window con kind (Table | Group) y loader para Groups +status: pending +priority: high +created: 2026-05-04 +parent: 0036 +depends_on: [0036a] +--- + +## Objetivo + +Que la NodeGroups window admita dos kinds: `Table` (DuckDB-backed, +comportamiento actual) y `Group` (entidades hijas con `group_id` set). +La window se elige por el `type_ref` del contenedor; el loader y las +columnas mostradas se ramifican por kind. + +## Cambios + +### `NodeGroupsWindowState` extendido + +Anyadir campo: + +```cpp +enum class NodeGroupsKind { Table, Group }; +NodeGroupsKind kind = NodeGroupsKind::Table; +``` + +### Loaders por kind (en `node_groups.cpp`) + +- **kind = Table**: comportamiento actual (`node_groups_load_metadata` + + `node_groups_page_rows` sobre DuckDB). +- **kind = Group**: nuevo loader que hace + `SELECT id, name, type_ref, status, updated_at FROM entities + WHERE group_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?` + + `SELECT count(*)` para `total_rows`. + Las columnas que se renderizan son fijas: + `id, name, type_ref, status, updated_at`. + +Convertir el dispatch en un metodo o switch dentro de +`node_groups_load_metadata` y `node_groups_page_rows` que mire `kind`. + +### Columnas dinamicas en el render + +Hoy `views.cpp` (en la pintura de la window) asume las columnas +DuckDB. Adaptar para que cuando `kind == Group` use las columnas +fijas listadas arriba. + +### Apertura programatica + +Para que 0036c pueda abrir una window de Group, exponer una API +limpia tipo: + +```cpp +NodeGroupsWindowState* node_groups_open(AppState& app, + const std::string& container_id, + NodeGroupsKind kind); +``` + +Que crea la entrada en `app.node_groups_windows[container_id]` si no +existe, le pone el kind, y retorna puntero. El caller puede setear +`focus_request = true` antes de devolver el control al render. + +## Acceptance criteria + +- Tests pytest siguen verdes. +- Manual: insertar via SQL un nodo `Group` con 5 entidades hijas + (`group_id = `), luego en la app llamar a + `node_groups_open(app, "", Group)` (o disparar via test + unitario en C++ si se incluye), recargar render → la window + muestra las 5 entidades con columnas id/name/type_ref/status/ + updated_at correctas. +- Apertura de un Table existente (kind=Table) sigue funcionando + identico (regresion). + +## TBD + +Branch `issue/0036b-kind-and-group-loader`, merge `--no-ff` a master. + +## Out of scope + +- Disparar drill-in desde doble click sobre Group (es 0036c). +- Promote / row click (0036d-e). diff --git a/main.cpp b/main.cpp index 2a7f526..e4bb047 100644 --- a/main.cpp +++ b/main.cpp @@ -1697,11 +1697,15 @@ static void render() { g_app.want_toggle_nodegroups = false; g_app.toggle_nodegroups_id.clear(); } - // Cierre via X de la ventana -> bajar expanded en BD. + // Cierre via X de la ventana -> bajar expanded en BD (solo kind=Table). + // En kind=Group no hay metadata `expanded`; basta con borrar la entry. for (auto it = g_app.node_groups_windows.begin(); it != g_app.node_groups_windows.end(); ) { - if (!it->second.open && !g_input_path.empty()) { - ge::node_groups_set_expanded(g_input_path.c_str(), - it->first.c_str(), false); + if (!it->second.open) { + if (it->second.kind == ge::NodeGroupsKind::Table + && !g_input_path.empty()) { + ge::node_groups_set_expanded(g_input_path.c_str(), + it->first.c_str(), false); + } it = g_app.node_groups_windows.erase(it); } else ++it; } @@ -1711,6 +1715,36 @@ static void render() { if (!w.page_dirty) continue; const auto& m = w.meta; w.last_error.clear(); + + if (w.kind == ge::NodeGroupsKind::Group) { + // kind=Group: contar y paginar entidades hijas via group_id. + bool ok_count = ge::node_groups_count_for_group( + g_input_path.c_str(), + m.entity_id.c_str(), &w.total_rows); + if (!ok_count) { + char buf[256]; + std::snprintf(buf, sizeof(buf), + "group count failed | container=%s", m.entity_id.c_str()); + w.last_error = buf; + std::fprintf(stderr, "[graph_explorer] %s\n", buf); + } + bool ok_page = ge::node_groups_page_for_group( + g_input_path.c_str(), + m.entity_id.c_str(), + w.offset, 200, &w.page); + if (!ok_page && w.last_error.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), + "group page query failed | offset=%lld limit=200", + (long long)w.offset); + w.last_error = buf; + std::fprintf(stderr, "[graph_explorer] %s\n", buf); + } + w.page_dirty = false; + continue; + } + + // kind=Table: comportamiento original (DuckDB). bool ok_count = ge::node_groups_count(m.duckdb_path_abs.c_str(), m.table_name.c_str(), m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(), diff --git a/node_groups.cpp b/node_groups.cpp index 79baa5b..f942e9e 100644 --- a/node_groups.cpp +++ b/node_groups.cpp @@ -807,4 +807,93 @@ bool node_groups_ingest_file(const char* duckdb_path, return true; } +// ---------------------------------------------------------------------------- +// Issue 0036b — kind=Group loaders sobre operations.db +// ---------------------------------------------------------------------------- + +bool node_groups_count_for_group(const char* ops_db, + const char* container_id, + int64_t* out_total) +{ + if (!ops_db || !container_id || !out_total) return false; + *out_total = 0; + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = "SELECT count(*) FROM entities WHERE group_id = ?"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, container_id, -1, SQLITE_TRANSIENT); + bool ok = false; + if (sqlite3_step(st) == SQLITE_ROW) { + *out_total = sqlite3_column_int64(st, 0); + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +bool node_groups_page_for_group(const char* ops_db, + const char* container_id, + int64_t offset, int64_t limit, + std::vector* out_rows) +{ + if (!ops_db || !container_id || !out_rows) return false; + out_rows->clear(); + if (limit < 1) limit = 1; + if (limit > 5000) limit = 5000; + + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + // Columnas fijas: id, name, type_ref, status, updated_at — el orden + // espejea la lista que pre-pobla views_node_groups_open() en meta.columns + // para que el render se mantenga generico. + const char* sql = + "SELECT id, name, type_ref, status, updated_at " + "FROM entities WHERE group_id = ? " + "ORDER BY updated_at DESC " + "LIMIT ? OFFSET ?"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text (st, 1, container_id, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 2, limit); + sqlite3_bind_int64(st, 3, offset); + while (sqlite3_step(st) == SQLITE_ROW) { + NodeGroupsRow row; + auto col_text = [&](int i) -> std::string { + const unsigned char* p = sqlite3_column_text(st, i); + return p ? std::string((const char*)p) : std::string(); + }; + // id va en NodeGroupsRow.id (key natural) y tambien en values[0] + // para que el render pinte la columna "id" igual que las demas. + row.id = col_text(0); + row.values.reserve(5); + row.values.push_back(row.id); // id + row.values.push_back(col_text(1)); // name + row.values.push_back(col_text(2)); // type_ref + row.values.push_back(col_text(3)); // status + row.values.push_back(col_text(4)); // updated_at + // promoted_entity_id no aplica en kind=Group — la fila YA es una + // entidad real del grafo, asi que la dejamos vacia (el render + // mostrara "-" en la columna 'promoted', que ocultamos para Group + // mas abajo en views.cpp). + out_rows->push_back(std::move(row)); + } + sqlite3_finalize(st); + sqlite3_close(db); + return true; +} + } // namespace ge diff --git a/node_groups.h b/node_groups.h index 8a64cdd..e2450d5 100644 --- a/node_groups.h +++ b/node_groups.h @@ -159,4 +159,21 @@ bool node_groups_list_columns(const char* duckdb_path, const char* duck_table, std::vector* out); +// ---------------------------------------------------------------------------- +// Issue 0036b — kind discriminator + Group loader +// ---------------------------------------------------------------------------- + +// Loaders especificos para kind=Group. Operan sobre operations.db +// consultando `entities` filtrando por `group_id`. Las columnas del +// result son fijas (id, name, type_ref, status, updated_at) y se mapean +// a NodeGroupsRow.values en ese orden. Devuelven false si la query falla. +bool node_groups_count_for_group(const char* ops_db, + const char* container_id, + int64_t* out_total); + +bool node_groups_page_for_group(const char* ops_db, + const char* container_id, + int64_t offset, int64_t limit, + std::vector* out_rows); + } // namespace ge diff --git a/tests/test_node_groups_loader.py b/tests/test_node_groups_loader.py new file mode 100644 index 0000000..cafa42e --- /dev/null +++ b/tests/test_node_groups_loader.py @@ -0,0 +1,110 @@ +"""Tests del loader NodeGroups kind=Group (issue 0036b). + +El binario C++ implementa `node_groups_count_for_group` y +`node_groups_page_for_group` sobre operations.db. El subcomando +`gx-cli group page ` espejea exactamente esa query, asi +estos tests verifican el contrato SQL: count + columnas fijas +(id, name, type_ref, status, updated_at) + ORDER BY updated_at DESC. + +Reusa la fixture `env_dirs` del modulo de tests del CLI. +""" +from __future__ import annotations + +import sqlite3 + +import pytest + +from test_gx_cli import OPS_SCHEMA, APP_SCHEMA, env_dirs, run_gx # noqa: F401 + + +def _seed_group_with_children(ops_db, group_id: str, n_children: int): + """Inserta un Group + n_children entidades hijas con group_id seteado. + + Las hijas se crean con updated_at distintos (separados un milisegundo) + para que el ORDER BY updated_at DESC tenga orden estable y verificable. + """ + cn = sqlite3.connect(ops_db) + try: + # El Group contenedor — type_ref='Group' (definicion en 0035a). + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, created_at, updated_at) " + "VALUES (?, ?, 'Group', 'active', 'manual', '{}', " + " '2026-05-04T10:00:00.000Z', '2026-05-04T10:00:00.000Z')", + (group_id, "test-group"), + ) + # Hijas: Word entities con group_id apuntando al Group. Generamos + # updated_at descendente para que el orden ORDER BY DESC sea + # determinista (la primera insertada queda al final del ranking). + for i in range(n_children): + child_id = f"word_{i:02d}" + ts = f"2026-05-04T11:{i:02d}:00.000Z" + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, group_id, " + " created_at, updated_at) " + "VALUES (?, ?, 'Word', 'active', 'manual', '{}', ?, ?, ?)", + (child_id, f"word-{i}", group_id, ts, ts), + ) + # Una entidad sin group_id para verificar que NO aparece en el page. + cn.execute( + "INSERT INTO entities(id, name, type_ref, status, source, " + " metadata, created_at, updated_at) " + "VALUES ('orphan_01', 'orphan', 'Word', 'active', 'manual', '{}', " + " '2026-05-04T09:00:00.000Z', '2026-05-04T09:00:00.000Z')" + ) + cn.commit() + finally: + cn.close() + + +class TestGroupPage: + def test_count_matches_children(self, env_dirs): + _seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=5) + out = run_gx(env_dirs, "group", "page", "grp_alpha") + assert out["total"] == 5, out + assert len(out["rows"]) == 5 + + def test_columns_are_fixed_set(self, env_dirs): + _seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3) + out = run_gx(env_dirs, "group", "page", "grp_alpha") + assert out["rows"], out + first = out["rows"][0] + assert set(first.keys()) == {"id", "name", "type_ref", + "status", "updated_at"} + + def test_orphan_not_listed(self, env_dirs): + _seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=3) + out = run_gx(env_dirs, "group", "page", "grp_alpha") + ids = {r["id"] for r in out["rows"]} + assert "orphan_01" not in ids + assert ids == {"word_00", "word_01", "word_02"} + + def test_order_by_updated_at_desc(self, env_dirs): + _seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=4) + out = run_gx(env_dirs, "group", "page", "grp_alpha") + ids_in_order = [r["id"] for r in out["rows"]] + # word_03 tiene el updated_at mas reciente (11:03), word_00 el mas + # antiguo (11:00). El ORDER BY ... DESC los pone en ese orden. + assert ids_in_order == ["word_03", "word_02", "word_01", "word_00"] + + def test_pagination_offset_limit(self, env_dirs): + _seed_group_with_children(env_dirs["ops"], "grp_alpha", n_children=10) + page1 = run_gx(env_dirs, "group", "page", "grp_alpha", + "--limit", "3", "--offset", "0") + page2 = run_gx(env_dirs, "group", "page", "grp_alpha", + "--limit", "3", "--offset", "3") + assert page1["total"] == 10 + assert page2["total"] == 10 + ids1 = [r["id"] for r in page1["rows"]] + ids2 = [r["id"] for r in page2["rows"]] + # No deben solaparse y el orden global sigue DESC por updated_at. + assert set(ids1).isdisjoint(set(ids2)) + assert ids1 == ["word_09", "word_08", "word_07"] + assert ids2 == ["word_06", "word_05", "word_04"] + + def test_unknown_container_returns_empty(self, env_dirs): + # Sin seed — la BD esta vacia. + out = run_gx(env_dirs, "group", "page", "no_such_group") + assert out["total"] == 0 + assert out["rows"] == [] diff --git a/views.cpp b/views.cpp index e7a4506..1823b44 100644 --- a/views.cpp +++ b/views.cpp @@ -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(); } diff --git a/views.h b/views.h index 8b6ca1c..de87486 100644 --- a/views.h +++ b/views.h @@ -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 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 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 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