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:
@@ -560,6 +560,38 @@ def cmd_table_page(args) -> None:
|
|||||||
"offset": args.offset, "limit": args.limit, "rows": rows})
|
"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
|
# enricher ops
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -841,6 +873,13 @@ MCP_TOOLS = [
|
|||||||
"description": "Borra la entidad promovida. La fila DuckDB queda intacta.",
|
"description": "Borra la entidad promovida. La fila DuckDB queda intacta.",
|
||||||
"inputSchema": {"type": "object", "properties": {
|
"inputSchema": {"type": "object", "properties": {
|
||||||
"id": {"type": "string"}}, "required": ["id"]}},
|
"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",
|
{"name": "enricher_list",
|
||||||
"description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.",
|
"description": "Lista enrichers cargados. Si se pasa type, filtra por applies_to.",
|
||||||
"inputSchema": {"type": "object", "properties": {
|
"inputSchema": {"type": "object", "properties": {
|
||||||
@@ -902,6 +941,7 @@ MCP_DISPATCH = {
|
|||||||
"table_page": (cmd_table_page, {"offset": 0, "limit": 50}),
|
"table_page": (cmd_table_page, {"offset": 0, "limit": 50}),
|
||||||
"table_promote": (cmd_table_promote, {}),
|
"table_promote": (cmd_table_promote, {}),
|
||||||
"table_demote": (cmd_table_demote, {}),
|
"table_demote": (cmd_table_demote, {}),
|
||||||
|
"group_page": (cmd_group_page, {"offset": 0, "limit": 200}),
|
||||||
"enricher_list": (cmd_enricher_list, {"type": None}),
|
"enricher_list": (cmd_enricher_list, {"type": None}),
|
||||||
"enricher_run": (cmd_enricher_run, {"node": None, "params": None}),
|
"enricher_run": (cmd_enricher_run, {"node": None, "params": None}),
|
||||||
"query": (cmd_query, {"limit": 100}),
|
"query": (cmd_query, {"limit": 100}),
|
||||||
@@ -1093,6 +1133,15 @@ def main() -> None:
|
|||||||
sp.add_argument("--limit", type=int, default=50)
|
sp.add_argument("--limit", type=int, default=50)
|
||||||
sp.set_defaults(fn=cmd_table_page)
|
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
|
# enricher
|
||||||
e = sub.add_parser("enricher").add_subparsers(dest="op", required=True)
|
e = sub.add_parser("enricher").add_subparsers(dest="op", required=True)
|
||||||
sp = e.add_parser("list")
|
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).
|
||||||
@@ -1697,11 +1697,15 @@ static void render() {
|
|||||||
g_app.want_toggle_nodegroups = false;
|
g_app.want_toggle_nodegroups = false;
|
||||||
g_app.toggle_nodegroups_id.clear();
|
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(); ) {
|
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) {
|
||||||
ge::node_groups_set_expanded(g_input_path.c_str(),
|
if (it->second.kind == ge::NodeGroupsKind::Table
|
||||||
it->first.c_str(), false);
|
&& !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);
|
it = g_app.node_groups_windows.erase(it);
|
||||||
} else ++it;
|
} else ++it;
|
||||||
}
|
}
|
||||||
@@ -1711,6 +1715,36 @@ static void render() {
|
|||||||
if (!w.page_dirty) continue;
|
if (!w.page_dirty) continue;
|
||||||
const auto& m = w.meta;
|
const auto& m = w.meta;
|
||||||
w.last_error.clear();
|
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(),
|
bool ok_count = ge::node_groups_count(m.duckdb_path_abs.c_str(),
|
||||||
m.table_name.c_str(),
|
m.table_name.c_str(),
|
||||||
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
|
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
|
||||||
|
|||||||
@@ -807,4 +807,93 @@ bool node_groups_ingest_file(const char* duckdb_path,
|
|||||||
return true;
|
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
|
} // namespace ge
|
||||||
|
|||||||
@@ -159,4 +159,21 @@ bool node_groups_list_columns(const char* duckdb_path,
|
|||||||
const char* duck_table,
|
const char* duck_table,
|
||||||
std::vector<std::string>* out);
|
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
|
} // namespace ge
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
@@ -1873,6 +1873,78 @@ void views_table(AppState& app) {
|
|||||||
// Table node UI fase 2 (issue 0011) — ventana expandida + import
|
// 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) {
|
void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
||||||
if (!app.graph || !ops_db) return;
|
if (!app.graph || !ops_db) return;
|
||||||
GraphData& g = *app.graph;
|
GraphData& g = *app.graph;
|
||||||
@@ -1914,8 +1986,11 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
|||||||
sqlite3_finalize(st);
|
sqlite3_finalize(st);
|
||||||
sqlite3_close(db);
|
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(); ) {
|
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);
|
if (live.find(it->first) == live.end()) it = app.node_groups_windows.erase(it);
|
||||||
else ++it;
|
else ++it;
|
||||||
}
|
}
|
||||||
@@ -1927,6 +2002,7 @@ void views_node_groups_windows_sync(AppState& app, const char* ops_db) {
|
|||||||
for (auto& kv : live) {
|
for (auto& kv : live) {
|
||||||
auto& w = app.node_groups_windows[kv.first];
|
auto& w = app.node_groups_windows[kv.first];
|
||||||
bool was_present = !w.meta.entity_id.empty();
|
bool was_present = !w.meta.entity_id.empty();
|
||||||
|
w.kind = NodeGroupsKind::Table; // expanded -> siempre Table
|
||||||
w.meta = std::move(kv.second);
|
w.meta = std::move(kv.second);
|
||||||
w.open = true;
|
w.open = true;
|
||||||
w.page_dirty = true;
|
w.page_dirty = true;
|
||||||
@@ -1947,101 +2023,160 @@ void views_node_groups_window(AppState& app) {
|
|||||||
NodeGroupsMeta& m = kv.second.meta;
|
NodeGroupsMeta& m = kv.second.meta;
|
||||||
AppState::NodeGroupsWindowState& w = kv.second;
|
AppState::NodeGroupsWindowState& w = kv.second;
|
||||||
|
|
||||||
|
const bool is_group = (w.kind == NodeGroupsKind::Group);
|
||||||
|
|
||||||
char title[160];
|
char title[160];
|
||||||
std::snprintf(title, sizeof(title), TI_TABLE " NodeGroups: %s##te_%s",
|
if (is_group) {
|
||||||
m.name.empty() ? "(unnamed)" : m.name.c_str(),
|
std::snprintf(title, sizeof(title), TI_TABLE " Group: %s##te_%s",
|
||||||
m.entity_id.c_str());
|
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);
|
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; }
|
if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; }
|
||||||
|
|
||||||
// Header de info
|
// Header de info (varia por kind)
|
||||||
ImGui::TextDisabled("%s · %s · %lld rows",
|
if (is_group) {
|
||||||
m.duckdb_path.c_str(), m.table_name.c_str(),
|
ImGui::TextDisabled("group_id=%s · %lld rows",
|
||||||
(long long)w.total_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()) {
|
if (!w.last_error.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
|
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f),
|
||||||
"ERROR: %s", w.last_error.c_str());
|
"ERROR: %s", w.last_error.c_str());
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
// Tabla
|
// Tabla — layout depende del kind:
|
||||||
const int col_count = (int)m.columns.size() + 2; // id + columns... + promoted
|
// 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 tflags =
|
||||||
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
||||||
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
|
ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable |
|
||||||
ImGuiTableFlags_SizingStretchProp;
|
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()))) {
|
ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) {
|
||||||
ImGui::TableSetupScrollFreeze(0, 1);
|
ImGui::TableSetupScrollFreeze(0, 1);
|
||||||
ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(),
|
if (is_group) {
|
||||||
ImGuiTableColumnFlags_WidthFixed, 100.0f);
|
for (size_t i = 0; i < m.columns.size(); ++i) {
|
||||||
for (const auto& c : m.columns) {
|
bool is_id = (i == 0);
|
||||||
ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch);
|
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();
|
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) {
|
for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) {
|
||||||
const NodeGroupsRow& row = w.page[i];
|
const NodeGroupsRow& row = w.page[i];
|
||||||
ImGui::TableNextRow();
|
ImGui::TableNextRow();
|
||||||
ImGui::PushID((int)(w.offset + i));
|
ImGui::PushID((int)(w.offset + i));
|
||||||
|
|
||||||
ImGui::TableSetColumnIndex(0);
|
ImGui::TableSetColumnIndex(0);
|
||||||
bool is_promoted = !row.promoted_entity_id.empty();
|
|
||||||
|
|
||||||
// Selectable spanning para que el doble-click y el right-click
|
// 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 sf = ImGuiSelectableFlags_SpanAllColumns
|
||||||
| ImGuiSelectableFlags_AllowDoubleClick;
|
| ImGuiSelectableFlags_AllowDoubleClick;
|
||||||
ImGui::Selectable(row.id.c_str(), false, sf);
|
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.want_focus_entity = true;
|
||||||
app.focus_entity_id = row.promoted_entity_id;
|
app.focus_entity_id = row.id;
|
||||||
} else {
|
|
||||||
app.want_promote_row = true;
|
|
||||||
app.promote_table_id = m.entity_id;
|
|
||||||
app.promote_row_id = row.id;
|
|
||||||
}
|
}
|
||||||
}
|
if (ImGui::BeginPopupContextItem()) {
|
||||||
if (ImGui::BeginPopupContextItem()) {
|
|
||||||
if (is_promoted) {
|
|
||||||
if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) {
|
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.want_focus_entity = true;
|
||||||
app.focus_entity_id = row.promoted_entity_id;
|
app.focus_entity_id = row.promoted_entity_id;
|
||||||
}
|
} else {
|
||||||
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.want_promote_row = true;
|
||||||
app.promote_table_id = m.entity_id;
|
app.promote_table_id = m.entity_id;
|
||||||
app.promote_row_id = row.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) {
|
for (size_t c = 0; c < m.columns.size(); ++c) {
|
||||||
ImGui::TableSetColumnIndex(1 + (int)c);
|
ImGui::TableSetColumnIndex(1 + (int)c);
|
||||||
if (c < row.values.size())
|
if (c < row.values.size())
|
||||||
ImGui::TextUnformatted(row.values[c].c_str());
|
ImGui::TextUnformatted(row.values[c].c_str());
|
||||||
}
|
}
|
||||||
ImGui::TableSetColumnIndex(col_count - 1);
|
ImGui::TableSetColumnIndex(col_count - 1);
|
||||||
if (is_promoted) {
|
if (is_promoted) {
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||||
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
|
ImVec4(0.6f, 0.95f, 0.6f, 1.0f));
|
||||||
ImGui::TextUnformatted("yes");
|
ImGui::TextUnformatted("yes");
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextDisabled("-");
|
ImGui::TextDisabled("-");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ struct GraphViewportState;
|
|||||||
|
|
||||||
namespace ge {
|
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
|
// Estado compartido entre las vistas y el bucle render. Pasado por puntero
|
||||||
// desde main.cpp.
|
// desde main.cpp.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
@@ -160,19 +165,26 @@ struct AppState {
|
|||||||
// Refrescado tras load_input y tras mutaciones que afecten a Tables.
|
// Refrescado tras load_input y tras mutaciones que afecten a Tables.
|
||||||
std::unordered_map<uint64_t, int64_t> node_groups_counts;
|
std::unordered_map<uint64_t, int64_t> node_groups_counts;
|
||||||
|
|
||||||
// ---- NodeGroups window (issue 0011, renombrado en 0036a) --------------
|
// ---- NodeGroups window (issue 0011, renombrado en 0036a, kind en 0036b) -
|
||||||
// Estado runtime por ventana de NodeGroups (un Table-typed expandido).
|
// Estado runtime por ventana de NodeGroups. Hay dos kinds (issue 0036b):
|
||||||
// Una entrada por entity_id de Table que el usuario haya expandido. La
|
// - Table: respaldada por DuckDB (el comportamiento original — un nodo
|
||||||
// ventana se cierra cuando set_expanded(false) — ya sea desde context
|
// `Table` del grafo que apunta a un .duckdb + table_name).
|
||||||
// menu o cerrando la ImGui window (que pone el flag a false
|
// - Group: respaldada por la propia operations.db. Lista las entidades
|
||||||
// automaticamente).
|
// 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 {
|
struct NodeGroupsWindowState {
|
||||||
|
NodeGroupsKind kind = NodeGroupsKind::Table; // default compat 0036a
|
||||||
NodeGroupsMeta meta; // refrescada cada vez que entity cambia
|
NodeGroupsMeta meta; // refrescada cada vez que entity cambia
|
||||||
int64_t total_rows = 0;
|
int64_t total_rows = 0;
|
||||||
int64_t offset = 0;
|
int64_t offset = 0;
|
||||||
std::vector<NodeGroupsRow> page;
|
std::vector<NodeGroupsRow> page;
|
||||||
bool page_dirty = true;
|
bool page_dirty = true;
|
||||||
bool open = true; // bound a ImGui::Begin
|
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::string last_error; // ultimo error de query (vacio = OK)
|
||||||
};
|
};
|
||||||
std::unordered_map<std::string, NodeGroupsWindowState> node_groups_windows;
|
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.
|
// entradas para nuevos expanded y borra las que ya no aplican.
|
||||||
void views_node_groups_windows_sync(AppState& app, const char* ops_db);
|
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) ------------------------------------
|
// ---- Table node overlay (issue 0010) ------------------------------------
|
||||||
|
|
||||||
// Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo
|
// Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo
|
||||||
|
|||||||
Reference in New Issue
Block a user