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
+49
View File
@@ -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")
@@ -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 = <ese group>`), luego en la app llamar a
`node_groups_open(app, "<group_id>", 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).
+36 -2
View File
@@ -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()) {
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(),
+89
View File
@@ -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<NodeGroupsRow>* 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
+17
View File
@@ -159,4 +159,21 @@ bool node_groups_list_columns(const char* duckdb_path,
const char* duck_table,
std::vector<std::string>* 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<NodeGroupsRow>* out_rows);
} // namespace ge
+110
View File
@@ -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 <container_id>` 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"] == []
+146 -11
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,32 +2023,66 @@ 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];
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
// 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);
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) {
@@ -1980,25 +2090,49 @@ void views_node_groups_window(AppState& app) {
}
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 (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.id;
}
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;
@@ -2043,6 +2177,7 @@ void views_node_groups_window(AppState& app) {
} else {
ImGui::TextDisabled("-");
}
}
ImGui::PopID();
}
ImGui::EndTable();
+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