Files
graph_explorer/main.cpp
T
egutierrez fdd169bc35 feat(0037): placement direccional 45 grados de orphans (away from centroide)
Antes los hijos del mismo anchor se distribuian en un anillo de 360
grados alrededor del padre. Cuando un enricher producia 10+ hijos,
se llenaban todas las direcciones y se pisaban nodos preexistentes.

Ahora los hijos se reparten en un abanico de 45 grados (pi/4) saliendo
del anchor en la direccion outward (vector anchor - centroide del resto
del grafo). Si solo hay 1 nodo placed o coincide con el anchor, default
a la derecha (0 rad). Capacidad por anillo restringida al arco
(arc_span * r / min_dist), con fallback de subida de radio en mismo
angulo si el slot ideal colisiona con un nodo no-orphan.

Solo afecta la pasada 2 (orphans con anchor). Pasadas 1 y 3 intactas.
build limpio, 102 pytest passed (WSL) + 91 passed/11 skipped (Windows).

Refs: issues/0037-directional-orphan-placement.md
2026-05-04 01:27:11 +02:00

2576 lines
108 KiB
C++

#include "app_base.h"
#include "imgui.h"
#include "core/fullscreen_window.h"
#include "core/app_about.h"
#include "core/app_settings.h"
#include "core/panel_menu.h"
#include "core/button.h"
#include "core/tokens.h"
#include "core/icons_tabler.h"
#include "core/layout_storage.h"
#include "core/layouts_menu.h"
#include "viz/graph_types.h"
#include "viz/graph_viewport.h"
#include "viz/graph_renderer.h"
#include "viz/graph_force_layout.h"
#include "viz/graph_force_layout_gpu.h"
#include "viz/graph_layouts.h"
#include "viz/graph_labels.h"
#include "viz/graph_icons.h"
#include "viz/graph_sources.h"
#include "data.h"
#include "views.h"
#include "types_registry.h"
#include "layout_store.h"
#include "entity_ops.h"
#include "project_manager.h"
#include "jobs.h"
#include "enrichers.h"
#include "chat.h"
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
#include "node_groups.h"
#include "duckdb.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <cmath>
#include <string>
#include <sys/stat.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#ifndef _WIN32
#include <unistd.h>
#else
#include <direct.h>
#define getcwd _getcwd
#endif
// ----------------------------------------------------------------------------
// Estado global de la app
// ----------------------------------------------------------------------------
static GraphData g_graph{};
static GraphViewportState g_viewport;
static ge::AppState g_app;
static ge::InputArgs g_input;
static std::string g_input_path; // copia para que .uri sea estable
static std::string g_types_path;
static std::string g_layout_initial; // --layout flag
static uint64_t g_graph_hash = 0;
static bool g_loaded = false;
// Project state — paths derivados del proyecto activo. En modo legacy
// (--input/positional explicito), `g_active_project` queda vacio y los paths
// vienen del CLI directamente.
static std::string g_active_project;
static std::string g_layout_db_path; // ruta de graph_explorer.db
// Force layout GPU context (lazy init).
static ForceLayoutGPU* g_gpu_ctx = nullptr;
static bool g_gpu_dirty = true;
// Layout storage (menu Layouts) — guardado/cargado de layouts ImGui en
// graph_explorer.db tabla imgui_layouts.
static fn_ui::LayoutStorage* g_layout_storage = nullptr;
static fn_ui::LayoutCallbacks g_layout_cb{};
// ----------------------------------------------------------------------------
// Persistencia de paneles abiertos/cerrados
//
// Los toggles `panel_chat`, `panel_jobs`, etc. viven en AppState (RAM). Sin
// persistencia, al reabrir la app vuelven a sus defaults — el usuario tiene
// que reabrir manualmente cada panel cada vez.
//
// Tabla `panel_state(name TEXT PK, open INT, updated_at INT)` en la misma
// graph_explorer.db. load al arrancar, save al cerrar.
// ----------------------------------------------------------------------------
static void panel_state_ensure_table(sqlite3* db) {
sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS panel_state ("
" name TEXT PRIMARY KEY,"
" open INTEGER NOT NULL,"
" updated_at INTEGER NOT NULL)",
nullptr, nullptr, nullptr);
}
static void panel_state_load_db(const std::string& db_path,
fn_ui::PanelToggle* panels, size_t n) {
if (db_path.empty()) return;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
panel_state_ensure_table(db);
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT open FROM panel_state WHERE name = ?",
-1, &st, nullptr) == SQLITE_OK) {
int restored = 0;
for (size_t i = 0; i < n; ++i) {
if (!panels[i].open || !panels[i].label) continue;
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
*panels[i].open = (sqlite3_column_int(st, 0) != 0);
++restored;
}
sqlite3_reset(st);
}
sqlite3_finalize(st);
std::fprintf(stdout,
"[graph_explorer] panel_state: restored %d/%zu panels\n",
restored, n);
}
sqlite3_close(db);
}
static void panel_state_save_db(const std::string& db_path,
const fn_ui::PanelToggle* panels, size_t n) {
if (db_path.empty()) return;
sqlite3* db = nullptr;
if (sqlite3_open_v2(db_path.c_str(), &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE,
nullptr) != SQLITE_OK) {
if (db) sqlite3_close(db);
return;
}
panel_state_ensure_table(db);
sqlite3_stmt* st = nullptr;
const char* sql =
"INSERT INTO panel_state(name, open, updated_at) "
"VALUES (?, ?, strftime('%s','now')) "
"ON CONFLICT(name) DO UPDATE SET "
" open = excluded.open, "
" updated_at = excluded.updated_at";
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) == SQLITE_OK) {
int saved = 0;
for (size_t i = 0; i < n; ++i) {
if (!panels[i].open || !panels[i].label) continue;
sqlite3_bind_text(st, 1, panels[i].label, -1, SQLITE_TRANSIENT);
sqlite3_bind_int (st, 2, *panels[i].open ? 1 : 0);
if (sqlite3_step(st) == SQLITE_DONE) ++saved;
sqlite3_reset(st);
}
sqlite3_finalize(st);
std::fprintf(stdout,
"[graph_explorer] panel_state: saved %d/%zu panels\n", saved, n);
}
sqlite3_close(db);
}
// Icon atlas (de types.yaml)
static IconAtlas* g_atlas = nullptr;
static bool g_atlas_bound = false;
// Para detectar primera invocacion de viewport (necesitamos el renderer creado)
static bool g_first_render = true;
// FPS estimate
static auto g_last_frame = std::chrono::steady_clock::now();
static int g_frames_acc = 0;
static auto g_fps_timer = std::chrono::steady_clock::now();
// Label policy
static graph::LabelPolicy g_label_policy;
// Indice user_data -> sql id (rebuild en cada load).
static ge::EntityIndex g_idx;
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
static int layout_name_to_index(const std::string& s) {
if (s == "force") return 0;
if (s == "grid") return 1;
if (s == "circular") return 2;
if (s == "radial") return 3;
if (s == "hierarchical") return 4;
if (s == "fixed") return 5;
return -1;
}
// ----------------------------------------------------------------------------
// Halo placement de nodos huerfanos (issue 0031)
// ----------------------------------------------------------------------------
// True si la posicion candidata (cx, cy) no colisiona con ningun nodo del
// grafo distinto de `self_idx`, considerando min_dist como umbral entre
// centros.
static bool layout_no_collision(const GraphData& g, int self_idx,
float cx, float cy, float min_dist)
{
const float md2 = min_dist * min_dist;
for (int i = 0; i < g.node_count; ++i) {
if (i == self_idx) continue;
const GraphNode& o = g.nodes[i];
// Ignora nodos que tampoco tienen posicion asignada — no son
// un obstaculo todavia, los colocara este mismo pase.
if (o.x == 0.0f && o.y == 0.0f) continue;
float dx = o.x - cx, dy = o.y - cy;
if (dx * dx + dy * dy < md2) return false;
}
return true;
}
// Devuelve el indice del primer vecino de `node_idx` que tenga posicion
// asignada (no (0,0)). -1 si ninguno la tiene.
static int layout_first_placed_neighbor(const GraphData& g, int node_idx) {
for (int e = 0; e < g.edge_count; ++e) {
int other = -1;
if ((int)g.edges[e].source == node_idx) other = (int)g.edges[e].target;
else if ((int)g.edges[e].target == node_idx) other = (int)g.edges[e].source;
if (other < 0 || other >= g.node_count) continue;
const GraphNode& n = g.nodes[other];
if (n.x != 0.0f || n.y != 0.0f) return other;
}
return -1;
}
// Encuentra una posicion sin colision para `self_idx` haciendo un barrido
// de slots angulares en radios crecientes alrededor de (cx, cy). Devuelve
// true y escribe (out_x, out_y); false si no hay hueco en los radios
// disponibles. `seed` se usa para jitter deterministico (ej: user_data).
static bool find_collision_free_slot(const GraphData& g, int self_idx,
float cx, float cy, float min_dist,
uint64_t seed,
const float* radii, int n_radii,
float* out_x, float* out_y)
{
const int slots = 12;
const float two_pi = 6.28318530718f;
const float slot_arc = two_pi / slots;
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * slot_arc;
// Slot 0 = el centro (sin desplazamiento). Si no colisiona, perfecto.
if (layout_no_collision(g, self_idx, cx, cy, min_dist)) {
*out_x = cx; *out_y = cy;
return true;
}
for (int ri = 0; ri < n_radii; ++ri) {
float r = radii[ri];
for (int s = 0; s < slots; ++s) {
float a = jitter + s * slot_arc;
float px = cx + r * std::cos(a);
float py = cy + r * std::sin(a);
if (layout_no_collision(g, self_idx, px, py, min_dist)) {
*out_x = px; *out_y = py;
return true;
}
}
}
return false;
}
// Coloca todos los nodos del grafo que esten en (0,0):
// 1. Si tiene un vecino con posicion → ring placement junto al vecino.
// 2. Sin vecino: si `use_camera` → ring placement alrededor de la camara
// (cam_cx, cam_cy) con un radio inicial proporcional al zoom — asi
// los nodos creados por el agente aparecen DENTRO de la vista actual,
// sin solapar con lo que ya hay en pantalla.
// 3. Sin vecino y sin camera → fallback legacy: columna a la derecha del
// bbox (usado en first-load donde el viewport todavia no se ha hecho
// fit).
static void place_orphans_near_neighbors(GraphData& g, float min_dist,
bool use_camera = false,
float cam_cx = 0.0f,
float cam_cy = 0.0f,
float cam_radius = 120.0f) {
if (g.node_count == 0) return;
const float neighbor_radii[] = {80.0f, 140.0f, 200.0f, 280.0f, 400.0f};
const int n_neighbor_radii = (int)(sizeof(neighbor_radii) /
sizeof(neighbor_radii[0]));
// Anillos crecientes alrededor de la camara — empieza pequeno (cam_radius
// base ~viewport/zoom) para mantener los nuevos cerca del foco visual.
float cam_radii[6];
for (int i = 0; i < 6; ++i) cam_radii[i] = cam_radius * (1.0f + i * 0.6f);
// Bbox para fallback legacy (columna lateral) cuando use_camera=false.
float bbox_max_x = 0.0f, bbox_min_y = 0.0f;
bool bbox_init = false;
if (!use_camera) {
for (int i = 0; i < g.node_count; ++i) {
const GraphNode& n = g.nodes[i];
if (n.x == 0.0f && n.y == 0.0f) continue;
if (!bbox_init) {
bbox_max_x = n.x; bbox_min_y = n.y;
bbox_init = true;
} else {
if (n.x > bbox_max_x) bbox_max_x = n.x;
if (n.y < bbox_min_y) bbox_min_y = n.y;
}
}
}
float park_x = bbox_init ? bbox_max_x + 120.0f : 0.0f;
float park_y = bbox_init ? bbox_min_y : 0.0f;
int park_n = 0;
int placed_neighbor = 0, placed_camera = 0, parked = 0;
// ----- Pase 1: agrupar orphans por su anchor (vecino con posicion) -----
// Cuando un enricher crea N nodos todos conectados al mismo source
// (caso tipico: web_search → N Urls SEARCH_RESULT_OF source), queremos
// que los N nodos clustereen MUY apretados alrededor del source en
// un solo anillo, no que se desperdiguen por anillos concentricos
// hasta encontrar slot libre. La busqueda anti-colision individual
// los empuja hacia fuera cuando ya hay vecinos preexistentes; aqui
// les damos a los hermanos del mismo anchor angulos repartidos en
// un anillo unico cerca del padre.
std::unordered_map<int, std::vector<int>> orphans_by_anchor;
std::vector<int> orphans_no_anchor;
for (int i = 0; i < g.node_count; ++i) {
const GraphNode& n = g.nodes[i];
if (n.x != 0.0f || n.y != 0.0f) continue;
int parent = layout_first_placed_neighbor(g, i);
if (parent >= 0) orphans_by_anchor[parent].push_back(i);
else orphans_no_anchor.push_back(i);
}
// ----- Pase 2: place clusters (orphans con anchor) -----
// Issue 0037: los hijos del mismo anchor se reparten en un ABANICO
// de 45 grados saliendo del anchor en la direccion outward (alejada
// del centroide del resto del grafo). Antes era 360 grados — con
// muchos hijos llenaba TODAS las direcciones y pisaba nodos
// preexistentes. Ahora el grafo crece direccionalmente por cada
// ejecucion de enricher.
const float pi = 3.14159265359f;
const float arc_span = pi / 4.0f; // 45 grados — issue 0037
for (auto& kv : orphans_by_anchor) {
int parent = kv.first;
std::vector<int>& kids = kv.second;
if (kids.empty()) continue;
// Orden estable por user_data para que rondas sucesivas del
// mismo enricher (mismo set de hijos) coloquen igual.
std::sort(kids.begin(), kids.end(),
[&](int a, int b) {
return g.nodes[a].user_data < g.nodes[b].user_data;
});
float cx = g.nodes[parent].x;
float cy = g.nodes[parent].y;
// --- Outward direction: anchor - centroide(rest of graph) ---
// Excluir el propio anchor y los kids (que aun estan en (0,0)).
// Si solo hay 1 nodo placed o el centroide coincide con el
// anchor, default a la derecha (out_angle = 0).
float out_angle = 0.0f;
{
// Marcar kids para excluirlos rapido.
std::unordered_set<int> kid_set(kids.begin(), kids.end());
double sum_x = 0.0, sum_y = 0.0;
int n_other = 0;
for (int j = 0; j < g.node_count; ++j) {
if (j == parent) continue;
if (kid_set.count(j)) continue;
const GraphNode& nj = g.nodes[j];
if (nj.x == 0.0f && nj.y == 0.0f) continue;
sum_x += nj.x; sum_y += nj.y; ++n_other;
}
if (n_other > 0) {
float cent_x = (float)(sum_x / n_other);
float cent_y = (float)(sum_y / n_other);
float dx = cx - cent_x;
float dy = cy - cent_y;
if (dx != 0.0f || dy != 0.0f) {
out_angle = std::atan2(dy, dx);
}
}
}
// --- Distribucion en abanico: capacidad por anillo restringida ---
// cap_r = max(2, arc_span * r / min_dist). Para min_dist=60,
// r=80 -> cap=2; r=140 -> 2; r=200 -> 3; etc. Ajustamos a un
// minimo de 2 para que kids unitarios o duos no degeneren.
size_t n = kids.size();
size_t accum = 0;
int ri = 0;
size_t cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
for (size_t k = 0; k < n; ++k) {
// Avanzar de anillo cuando el actual se ha llenado.
while (k >= accum + cap_r && ri < n_neighbor_radii - 1) {
accum += cap_r;
++ri;
cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
}
if (k >= accum + cap_r) {
// Ultimo anillo desbordado: aceptamos solape angular.
cap_r = std::max((size_t)2, n - accum);
}
size_t slot = k - accum;
size_t slot_count = std::min(cap_r, n - accum);
float angle;
if (slot_count <= 1) {
angle = out_angle;
} else {
angle = out_angle - arc_span * 0.5f
+ (float)slot * (arc_span / (float)(slot_count - 1));
}
// Jitter pequeno por user_data — pequeno para no romper el arco.
uint64_t seed = g.nodes[kids[k]].user_data;
float jit_max = (arc_span / (float)cap_r) * 0.3f;
float jitter = (((float)((seed >> 16) & 0xFF) / 255.0f) - 0.5f) * jit_max;
angle += jitter;
float r_use = neighbor_radii[ri];
float px = cx + r_use * std::cos(angle);
float py = cy + r_use * std::sin(angle);
// Si colisiona con un nodo no-orphan (ya placed), subimos
// de radio en MISMO angulo hasta encontrar hueco. Si se
// agotan los radios, aceptamos solape en el actual.
GraphNode& kid = g.nodes[kids[k]];
if (!layout_no_collision(g, kids[k], px, py, min_dist)) {
bool found = false;
for (int ri2 = ri + 1; ri2 < n_neighbor_radii; ++ri2) {
float r2 = neighbor_radii[ri2];
float px2 = cx + r2 * std::cos(angle);
float py2 = cy + r2 * std::sin(angle);
if (layout_no_collision(g, kids[k], px2, py2, min_dist)) {
px = px2; py = py2;
found = true;
break;
}
}
(void)found; // ultimo recurso: solape coherente con la logica previa
}
kid.x = px; kid.y = py;
kid.vx = kid.vy = 0.0f;
++placed_neighbor;
}
}
// ----- Pase 3: place orphans sin anchor (camera o parking lot) -----
for (int i : orphans_no_anchor) {
GraphNode& n = g.nodes[i];
if (use_camera) {
// Sin vecino → colocar dentro de la camara con ring placement.
float ox, oy;
if (find_collision_free_slot(
g, i, cam_cx, cam_cy, min_dist, n.user_data,
cam_radii, 6, &ox, &oy)) {
n.x = ox; n.y = oy;
} else {
// Anillo amplio aceptando solape.
float two_pi = 6.28318530718f;
float a = ((float)((n.user_data >> 8) & 0xFFFF) / 65535.0f) * two_pi;
float r = cam_radii[5];
n.x = cam_cx + std::cos(a) * r;
n.y = cam_cy + std::sin(a) * r;
}
n.vx = n.vy = 0.0f;
++placed_camera;
continue;
}
// Legacy: columna lateral (fuera de cam — usado en first_load).
n.x = park_x;
n.y = park_y + park_n * min_dist;
n.vx = n.vy = 0.0f;
++park_n; ++parked;
}
if (placed_neighbor || placed_camera || parked) {
std::fprintf(stdout,
"[graph_explorer] placed %d near-neighbor, %d in-camera, %d parked\n",
placed_neighbor, placed_camera, parked);
}
}
static void apply_static_layout(int mode) {
if (g_graph.node_count == 0) return;
switch (mode) {
case 1: graph::layout_grid(g_graph, 20.0f); break;
case 2: graph::layout_circular(g_graph, 200.0f); break;
case 3: graph::layout_radial(g_graph, 0, 80.0f); break;
case 4: graph::layout_hierarchical(g_graph, 0, 120.0f, 60.0f); break;
case 5: graph::layout_fixed(g_graph); break;
case 0: default: break; // force: no-op (lo mueve el bucle)
}
g_gpu_dirty = true;
if (mode != 0) {
g_graph.update_bounds();
graph_viewport_fit(g_graph, g_viewport);
}
}
// Forward decl — definido mas abajo, lo necesita switch_to_project.
// `first_load=true` activa: layout_circular si todo (0,0), graph_viewport_fit.
// En reloads (first_load=false) ambos se omiten para preservar el estado del
// usuario (issue 0031).
static bool load_input(bool first_load = true);
// ----------------------------------------------------------------------------
// Registry path resolution (issue 0026)
// ----------------------------------------------------------------------------
#ifdef _WIN32
// Detecta la distro WSL "default" buscando que UNC `\\wsl.localhost\<name>\`
// existe y contiene `home/lucas/fn_registry/registry.db`. Devuelve "" si no
// encuentra ninguna. Probamos las distros comunes — el usuario sobrescribe
// con FN_REGISTRY_ROOT si tiene una con nombre raro.
static std::string detect_wsl_distro() {
const char* candidates[] = {
"Ubuntu", "Ubuntu-24.04", "Ubuntu-22.04", "Ubuntu-20.04",
"Debian", "kali-linux", "Fedora", "openSUSE-Tumbleweed",
nullptr
};
for (int i = 0; candidates[i]; ++i) {
std::string probe = std::string("\\\\wsl.localhost\\") + candidates[i] +
"\\home\\lucas\\fn_registry\\registry.db";
FILE* f = std::fopen(probe.c_str(), "rb");
if (f) { std::fclose(f); return candidates[i]; }
}
return "";
}
#endif
// Devuelve el path absoluto al root de fn_registry. Estrategia:
// 1) FN_REGISTRY_ROOT env var (acepta path Linux o UNC Windows
// `\\\\wsl.localhost\\<distro>\\home\\...`).
// 2) Sube desde getcwd() buscando un dir con `registry.db`.
// 3) En Windows, sondear UNCs de las distros comunes hasta encontrar
// una con `registry.db`. La build se distribuye al desktop fuera del
// arbol del registry, asi que getcwd nunca lo encuentra.
// 4) "" si no se encuentra (los enrichers quedan desactivados).
static std::string resolve_registry_root() {
if (const char* env = std::getenv("FN_REGISTRY_ROOT")) {
if (env && *env) return env;
}
char cwd[4096];
if (getcwd(cwd, sizeof(cwd)) != nullptr) {
std::string p = cwd;
#ifdef _WIN32
for (char& c : p) if (c == '\\') c = '/';
#endif
for (int i = 0; i < 8; ++i) {
std::string probe = p + "/registry.db";
FILE* f = std::fopen(probe.c_str(), "rb");
if (f) { std::fclose(f); return p; }
size_t s = p.find_last_of('/');
if (s == std::string::npos || s == 0) break;
p = p.substr(0, s);
}
}
#ifdef _WIN32
std::string distro = detect_wsl_distro();
if (!distro.empty()) {
return std::string("\\\\wsl.localhost\\") + distro +
"\\home\\lucas\\fn_registry";
}
std::fprintf(stderr,
"[graph_explorer] no se detecta la distro WSL — "
"setea FN_REGISTRY_ROOT con el UNC del registry.\n");
return "";
#else
return "";
#endif
}
// ----------------------------------------------------------------------------
// Project lifecycle
// ----------------------------------------------------------------------------
// Aplica los paths del proyecto `slug` a las globales (g_input_path,
// g_types_path, g_layout_db_path) y actualiza g_active_project. No abre BDs
// ni carga el grafo — eso lo hace el caller.
static void apply_project_paths(const std::string& slug) {
ge::ProjectPaths p = ge::project_paths(slug.c_str());
g_active_project = slug;
g_input_path = p.operations_db;
g_types_path = p.types_yaml;
g_layout_db_path = p.layout_db;
g_app.active_project = slug;
}
// Cambia al proyecto `slug`: cierra layout_store, libera grafo, abre BDs
// nuevas, carga grafo, persiste como last_active. Devuelve true en exito.
static bool switch_to_project(const std::string& slug) {
if (slug.empty()) return false;
if (!ge::project_exists(slug.c_str())) {
std::fprintf(stderr, "[graph_explorer] project '%s' no existe\n",
slug.c_str());
return false;
}
// Cierra estado del proyecto actual
ge::layout_store_close();
if (g_loaded) {
graph::graph_free(&g_graph);
g_loaded = false;
}
if (g_atlas) {
graph_icons_destroy(g_atlas);
g_atlas = nullptr;
}
g_atlas_bound = false;
g_viewport.selection.clear();
g_viewport.hovered_node = -1;
g_viewport.selected_node = -1;
// Aplica paths nuevos y abre BDs
apply_project_paths(slug);
ge::views_inspector_clear_draft(g_app);
g_app.parsed_types = ge::ParsedTypes{};
// Migracion idempotente del schema (issue 0035a y siguientes).
{
std::string mig_err;
if (!ge::project_migrate_schema(g_input_path, &mig_err)) {
std::fprintf(stderr,
"[graph_explorer] project_migrate_schema('%s') failed: %s\n",
g_input_path.c_str(), mig_err.c_str());
}
}
if (!ge::layout_store_open(g_layout_db_path.c_str())) {
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
g_layout_db_path.c_str());
}
bool ok = load_input();
if (ok) ge::project_settings_touch(slug.c_str());
return ok;
}
static bool load_input(bool first_load) {
g_input.kind = ge::INPUT_OPERATIONS;
g_input.uri = g_input_path.c_str();
graph::GraphLoadStats stats{};
bool ok = ge::load_graph(g_input, &g_graph, &stats);
if (!ok) {
std::fprintf(stderr, "[graph_explorer] load failed: %s\n", stats.error_msg);
return false;
}
// Filtro de grupos colapsados (issue 0035b). Se aplica tras la carga
// bruta — el loader sigue siendo agnostico al concepto de grupo.
ge::apply_group_filter(&g_graph, g_input.uri, g_app.group_expanded);
std::fprintf(stdout,
"[graph_explorer] loaded %d nodes, %d edges, %d types, %d rel_types from %s\n",
g_graph.node_count, g_graph.edge_count,
stats.types_discovered, stats.rel_types_discovered, g_input.uri);
// types.yaml
if (!g_types_path.empty()) {
ge::ParsedTypes pt;
std::string err;
if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
std::fprintf(stderr, "[graph_explorer] types.yaml: %s\n", err.c_str());
} else {
std::vector<uint16_t> codepoints = ge::apply_types_yaml(g_graph, pt);
// Reset atlas — la prox vez que el viewport tenga renderer, se baja
g_atlas_bound = false;
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(codepoints);
int total_fields = 0;
int with_schema = 0;
for (const auto& e : pt.entities) {
total_fields += (int)e.fields.size();
if (!e.fields.empty()) ++with_schema;
}
std::fprintf(stdout,
"[graph_explorer] types.yaml: %zu entities (%d con schema, %d fields totales),"
" %zu relations, %zu icons\n",
pt.entities.size(), with_schema, total_fields,
pt.relations.size(), codepoints.size());
// Stash en AppState para que el Inspector resuelva schemas (issue 0008).
g_app.parsed_types = std::move(pt);
}
}
// Inicializar el draft del Type Editor con copia de parsed_types (0007).
g_app.types_draft = g_app.parsed_types;
g_app.types_dirty = false;
g_app.types_save_error.clear();
// Restablecer viewport state (preserva camara user-visible). Physics
// arrancan en pausa para que las posiciones guardadas no se pierdan;
// el usuario las activa con el boton Physics de la toolbar.
g_viewport.selection.clear();
g_viewport.hovered_node = -1;
g_viewport.selected_node = -1;
g_viewport.layout_running = false;
g_viewport.layout_energy = 0.0f;
// Indice user_data -> sql id (para CRUD desde menu contextual).
ge::entity_index_build(g_input.uri, &g_idx);
g_app.input_db_path = g_input.uri ? g_input.uri : "";
// issue 0026 — apunta el JobRunner a la nueva operations.db.
if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
// Chat agent — refrescar contexto de la nueva operations.db.
if (g_input.uri) ge::chat_set_ops_db(g_input.uri);
// Cargar posiciones guardadas para este graph_hash. Ahora ANTES del
// bootstrap circular: si tenemos posiciones guardadas las respetamos;
// solo aplicamos circular si NO hay nada guardado en primera carga.
g_graph_hash = ge::compute_graph_hash(g_input.uri);
int restored = ge::layout_store_load(g_graph_hash, g_graph);
if (restored > 0) {
std::fprintf(stdout,
"[graph_explorer] restored %d node positions from layout store\n",
restored);
}
// Bootstrap circular SOLO si no se restauro nada en primera carga (ej:
// primer arranque tras crear el proyecto, o tras `Reset layout`). Si
// restored>0 los nodos cargados ya tienen posicion; los nuevos sin
// posicion guardada los colocara place_orphans_near_neighbors.
if (first_load && restored == 0 && g_graph.node_count > 0) {
graph::layout_circular(g_graph, 200.0f);
std::fprintf(stdout,
"[graph_explorer] bootstrap layout_circular (no saved positions)\n");
}
g_graph.update_bounds();
// Huerfanos (nodos sin posicion guardada): halo placement junto a su
// primer vecino con coordenadas conocidas (issue 0031). En primera carga
// tambien aplica — si layout_circular ya los puso en circulo, no entran
// (ya no estan en (0,0)). En reloads es donde mas valor da: nodos
// creados por enrichers caen junto a su padre semantico.
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
g_graph.update_bounds();
// Vista inicial — solo en primera carga; los reloads preservan camara
// del usuario (issue 0031).
if (first_load) {
graph_viewport_fit(g_graph, g_viewport);
}
g_gpu_dirty = true;
// App state — visibility por tipo
g_app.graph = &g_graph;
g_app.viewport = &g_viewport;
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
// Cache de conteos de Table nodes (issue 0010).
if (g_input.uri) {
ge::node_groups_refresh_counts(g_input.uri, &g_app.node_groups_counts);
int64_t total_rows = 0;
for (auto& kv : g_app.node_groups_counts) total_rows += kv.second;
std::fprintf(stdout,
"[graph_explorer] table counts refreshed: %zu tables, %lld total rows\n",
g_app.node_groups_counts.size(), (long long)total_rows);
// Sync de windows expandidas (issue 0011) — reabre las que el
// usuario tenia abiertas en la sesion previa (metadata.expanded=true).
ge::views_node_groups_windows_sync(g_app, g_input.uri);
}
// Cache de la vista tabla (issue 0004) — pull bulk + neighbors desde grafo.
{
std::vector<ge::EntityRowSnapshot> snap;
if (g_input.uri && ge::entity_list_rows(g_input.uri, &snap)) {
g_app.table_rows.clear();
g_app.table_rows.reserve(snap.size());
for (auto& s : snap) {
ge::AppState::TableRow tr;
tr.id = std::move(s.id);
tr.name = std::move(s.name);
tr.type_ref = std::move(s.type_ref);
tr.status = std::move(s.status);
tr.updated_at = std::move(s.updated_at);
tr.group_id = std::move(s.group_id);
g_app.table_rows.push_back(std::move(tr));
}
ge::views_table_refresh_indices(g_app);
g_app.table_cache_dirty = false;
}
}
// Inspector: refresca caches (tags distintas, lista de tipos) y limpia
// cualquier draft anterior. El draft se cargara cuando el usuario
// seleccione un nodo en el render loop.
ge::views_inspector_clear_draft(g_app);
ge::views_inspector_refresh_caches(g_app);
// --layout inicial (si llego del CLI)
int idx = layout_name_to_index(g_layout_initial);
if (idx >= 0) {
g_app.layout_mode = idx;
apply_static_layout(idx);
}
g_loaded = true;
return true;
}
static void run_force_step() {
if (!g_viewport.layout_running) return;
if (g_app.layout_mode != 0) return; // force solo en mode 0
ForceLayoutConfig cfg;
cfg.repulsion = g_app.repulsion;
cfg.attraction = g_app.attraction;
cfg.gravity = g_app.gravity;
cfg.iterations = 1;
// Tapa de energia: damping mas agresivo + max_velocity bajo evita que el
// grafo "explote" al cargar (nodos que arrancan cerca del origen y se
// dispersan con repulsion alta). Valores tuneados para sentir movimiento
// suave sin saltos visibles entre frames.
cfg.damping = 0.7f;
cfg.max_velocity = 8.0f;
if (g_app.use_gpu) {
if (!g_gpu_ctx) {
g_gpu_ctx = graph_force_layout_gpu_create(g_graph.node_count + 1024,
g_graph.edge_count + 1024);
g_gpu_dirty = true;
}
if (g_gpu_ctx) {
if (g_gpu_dirty) {
graph_force_layout_gpu_upload(g_gpu_ctx, g_graph);
g_gpu_dirty = false;
}
g_viewport.layout_energy = graph_force_layout_gpu_step(g_gpu_ctx, cfg);
graph_force_layout_gpu_readback(g_gpu_ctx, g_graph, /*include_velocities=*/true);
} else {
g_app.use_gpu = false;
g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
}
} else {
g_viewport.layout_energy = graph_force_layout_step(g_graph, cfg);
}
// Auto-pause heuristica: si energia/nodo es muy baja durante muchos
// frames, apagar simulacion. El usuario puede reanudarla con el toggle.
static int low = 0;
const float k_pause_per_node = 0.001f;
const int k_pause_after = 60;
float per = g_graph.node_count > 0
? g_viewport.layout_energy / (float)g_graph.node_count
: 0.0f;
if (per < k_pause_per_node) ++low;
else low = 0;
if (graph_force_layout_should_pause(low, k_pause_after)) {
g_viewport.layout_running = false;
low = 0;
}
}
// FPS estimate sintetico (por segundo).
static void update_fps() {
using namespace std::chrono;
auto now = steady_clock::now();
++g_frames_acc;
if (duration_cast<milliseconds>(now - g_fps_timer).count() >= 1000) {
g_app.fps_estimate = g_frames_acc;
g_frames_acc = 0;
g_fps_timer = now;
}
g_last_frame = now;
}
// ----------------------------------------------------------------------------
// Context menu callback (right-click sobre nodo)
// ----------------------------------------------------------------------------
// Doble click sobre nodo: solicita abrir el panel Note. main.cpp procesa
// despues (necesita acceso al EntityIndex para resolver el sql id).
static void on_double_click_cb(int node_idx, void* /*user*/) {
g_app.want_open_note = true;
g_app.open_note_target = node_idx;
}
static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) {
g_app.ctx_node = node_idx;
g_app.ctx_open_request = true;
if (node_idx >= 0 && node_idx < g_graph.node_count) {
const GraphNode& n = g_graph.nodes[node_idx];
if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s",
g_graph.types[n.type_id].name);
} else {
g_app.ctx_new_type[0] = 0;
}
}
}
// Lista de tipos disponibles para "Change type" — se construye desde el grafo
// activo. Si esta vacia, se usa una lista por defecto.
static const char* k_default_types[] = {
"text", "person", "organization", "email", "ip_address", "domain",
"url", "phone", "crypto_wallet", "malware", "vulnerability",
};
constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0]));
static void render_context_menu() {
if (g_app.ctx_open_request) {
ImGui::OpenPopup("##node_ctx");
g_app.ctx_open_request = false;
}
if (!ImGui::BeginPopup("##node_ctx")) return;
int idx = g_app.ctx_node;
if (idx < 0 || idx >= g_graph.node_count) {
ImGui::TextDisabled("(no node)");
ImGui::EndPopup();
return;
}
const GraphNode& n = g_graph.nodes[idx];
const char* lbl = graph::graph_label(&g_graph, n.label_idx);
ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)");
ImGui::Separator();
// Detectar si el nodo es Table y resolver entity_id para opciones tabla.
bool is_table = false;
if (n.type_id < (uint16_t)g_graph.type_count) {
const EntityType& t = g_graph.types[n.type_id];
if (t.name && std::strcmp(t.name, "Table") == 0) is_table = true;
}
const char* sql_id = ge::entity_index_lookup(g_idx, n.user_data);
if (is_table && sql_id) {
// Determinar estado actual sin ir a BD: mira node_groups_windows.
bool currently_open =
g_app.node_groups_windows.find(sql_id) != g_app.node_groups_windows.end();
const char* lbl_exp = currently_open
? TI_X " Close NodeGroups"
: TI_TABLE " Open NodeGroups";
if (ImGui::MenuItem(lbl_exp)) {
g_app.want_toggle_nodegroups = true;
g_app.toggle_nodegroups_id = sql_id;
}
ImGui::Separator();
}
if (ImGui::BeginMenu("Change type")) {
// Construye un set ordenado y deduplicado: tipos del grafo + defaults.
// Asi evitamos colisiones de ID en ImGui ("person" en grafo y default).
std::vector<const char*> all;
all.reserve(g_graph.type_count + k_default_types_n);
for (int i = 0; i < g_graph.type_count; ++i) {
if (g_graph.types[i].name && *g_graph.types[i].name) {
all.push_back(g_graph.types[i].name);
}
}
for (int i = 0; i < k_default_types_n; ++i) {
const char* d = k_default_types[i];
bool dup = false;
for (const char* x : all) { if (std::strcmp(x, d) == 0) { dup = true; break; } }
if (!dup) all.push_back(d);
}
for (size_t i = 0; i < all.size(); ++i) {
ImGui::PushID((int)i);
if (ImGui::MenuItem(all[i])) {
std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", all[i]);
g_app.want_change_type = true;
}
ImGui::PopID();
}
ImGui::EndMenu();
}
if (ImGui::MenuItem("Duplicate")) {
g_app.want_duplicate_node = true;
}
if (ImGui::MenuItem("Delete")) {
g_app.want_delete_node = true;
}
ImGui::Separator();
if (ImGui::BeginMenu("Run enricher")) {
// issue 0026 — listamos enrichers cuyo applies_to incluye este type.
const char* type_name = (n.type_id < (uint16_t)g_graph.type_count)
? g_graph.types[n.type_id].name : "";
const auto& all = ge::enrichers_all();
auto specs = ge::enrichers_for_type(type_name);
if (!sql_id) {
ImGui::TextDisabled("(node has no entity id)");
} else if (all.empty()) {
ImGui::TextDisabled("(no enrichers cargados)");
ImGui::TextDisabled("revisa FN_REGISTRY_ROOT");
} else if (specs.empty()) {
ImGui::TextDisabled("(0/%d enrichers para tipo '%s')",
(int)all.size(), type_name);
} else {
for (const auto& s : specs) {
if (ImGui::MenuItem(s.name.c_str())) {
if (s.params.empty()) {
// Sin params editables: submit directo, comportamiento
// historico — un click y a correr.
char job_id[64];
bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl,
"{}", job_id, sizeof(job_id));
if (ok) g_app.panel_jobs = true;
} else {
// Abrir ventana de configuracion. Inicializar
// buffers con los defaults del manifest.
g_app.enr_modal_id = s.id;
g_app.enr_modal_node_id = sql_id;
g_app.enr_modal_node_label = lbl ? lbl : "";
g_app.enr_modal_param_bufs.clear();
g_app.enr_modal_param_bufs.resize(s.params.size());
for (size_t i = 0; i < s.params.size(); ++i) {
const std::string& dv = s.params[i].default_value;
auto& buf = g_app.enr_modal_param_bufs[i];
buf.assign(256, '\0');
std::snprintf(buf.data(), buf.size(), "%s", dv.c_str());
}
g_app.enr_window_open = true;
}
}
if (!s.description.empty() && ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", s.description.c_str());
}
}
}
ImGui::EndMenu();
}
ImGui::EndPopup();
}
// ----------------------------------------------------------------------------
// Modal: configurar parametros de enricher antes de lanzar el job
// ----------------------------------------------------------------------------
// Se invoca desde el context menu (Run enricher → click). Si el enricher
// declara `params` en su manifest, en lugar de submitear directamente,
// llenamos el AppState (ver bloque `enr_modal_*`) y aqui renderizamos el
// dialogo. El usuario ajusta valores y al pulsar Run construimos el
// JSON `{ "param": value, ... }` y lo pasamos a `jobs_submit`.
static std::string json_escape_str(const std::string& s) {
std::string out;
out.reserve(s.size() + 8);
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char b[8];
std::snprintf(b, sizeof(b), "\\u%04x", (unsigned char)c);
out += b;
} else {
out.push_back(c);
}
}
}
return out;
}
// Renderiza una fila label/input dentro de una BeginTable de 2 columnas.
// El label va a la izquierda alineado al frame del input; el input usa
// todo el ancho disponible de la columna derecha.
static void labeled_row_begin(const char* label) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::TableNextColumn();
ImGui::SetNextItemWidth(-FLT_MIN);
}
static void render_enricher_config_window() {
if (!g_app.enr_window_open) return;
ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Run enricher", &g_app.enr_window_open,
ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;
}
const ge::EnricherSpec* spec = ge::enricher_by_id(g_app.enr_modal_id.c_str());
if (!spec) {
ImGui::TextDisabled("(enricher no encontrado)");
ImGui::End();
return;
}
ImGui::Text("%s", spec->name.c_str());
if (!spec->description.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.7f, 0.7f, 0.7f, 1.0f));
ImGui::TextWrapped("%s", spec->description.c_str());
ImGui::PopStyleColor();
}
ImGui::Separator();
ImGui::TextDisabled("Node: %s", g_app.enr_modal_node_label.c_str());
ImGui::Spacing();
// Asegurar tamaño de buffers — un manifest puede haberse recargado
// con mas params de los que llenamos al abrir la ventana.
if (g_app.enr_modal_param_bufs.size() < spec->params.size()) {
g_app.enr_modal_param_bufs.resize(spec->params.size());
}
if (ImGui::BeginTable("##enr_params", 2,
ImGuiTableFlags_SizingStretchProp |
ImGuiTableFlags_NoBordersInBody)) {
ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthFixed, 110.0f);
ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch);
for (size_t i = 0; i < spec->params.size(); ++i) {
const auto& p = spec->params[i];
auto& buf = g_app.enr_modal_param_bufs[i];
if (buf.size() < 256) buf.resize(256, '\0');
ImGui::PushID((int)i);
labeled_row_begin(p.name.c_str());
const std::string& t = p.type;
if (t == "int") {
int v = std::atoi(buf.data());
if (ImGui::InputInt("##v", &v, 1, 10)) {
std::snprintf(buf.data(), buf.size(), "%d", v);
}
} else if (t == "float" || t == "double" || t == "number") {
float v = (float)std::atof(buf.data());
if (ImGui::InputFloat("##v", &v)) {
std::snprintf(buf.data(), buf.size(), "%g", v);
}
} else if (t == "bool") {
bool v = (std::strcmp(buf.data(), "true") == 0 ||
std::strcmp(buf.data(), "1") == 0);
if (ImGui::Checkbox("##v", &v)) {
std::snprintf(buf.data(), buf.size(), "%s", v ? "true" : "false");
}
} else {
ImGui::InputText("##v", buf.data(), buf.size());
}
if (!p.description.empty() && ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", p.description.c_str());
}
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::Separator();
if (ImGui::Button("Run", ImVec2(100, 0))) {
// Construir JSON `{ "name": value, ... }` segun los tipos.
std::string j = "{";
for (size_t i = 0; i < spec->params.size(); ++i) {
const auto& p = spec->params[i];
const auto& buf = g_app.enr_modal_param_bufs[i];
if (i) j += ",";
j += "\"";
j += json_escape_str(p.name);
j += "\":";
if (p.type == "int") {
int v = std::atoi(buf.data());
char b[32]; std::snprintf(b, sizeof(b), "%d", v);
j += b;
} else if (p.type == "float" || p.type == "double" || p.type == "number") {
double v = std::atof(buf.data());
char b[64]; std::snprintf(b, sizeof(b), "%g", v);
j += b;
} else if (p.type == "bool") {
bool v = (std::strcmp(buf.data(), "true") == 0 ||
std::strcmp(buf.data(), "1") == 0);
j += v ? "true" : "false";
} else {
j += "\"";
j += json_escape_str(buf.data());
j += "\"";
}
}
j += "}";
char job_id[64];
bool ok = ge::jobs_submit(spec->id.c_str(),
g_app.enr_modal_node_id.c_str(),
g_app.enr_modal_node_label.c_str(),
j.c_str(), job_id, sizeof(job_id));
if (ok) g_app.panel_jobs = true;
g_app.enr_window_open = false;
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(100, 0))) {
g_app.enr_window_open = false;
}
ImGui::End();
}
// ----------------------------------------------------------------------------
// Label callback
// ----------------------------------------------------------------------------
static const char* get_label_cb(int node_idx, void* /*user*/) {
if (node_idx < 0 || node_idx >= g_graph.node_count) return "";
const GraphNode& n = g_graph.nodes[node_idx];
return graph::graph_label(&g_graph, n.label_idx);
}
// ----------------------------------------------------------------------------
// Render
// ----------------------------------------------------------------------------
static fn_ui::PanelToggle g_panels[] = {
{"Viewport", nullptr, &g_app.panel_viewport},
{"Legend", nullptr, &g_app.panel_legend},
{"Inspector", nullptr, &g_app.panel_inspector},
{"Stats", nullptr, &g_app.panel_stats},
{"Note", nullptr, &g_app.panel_note},
{"Types", nullptr, &g_app.panel_type_editor},
{"Table", nullptr, &g_app.panel_table},
{"Jobs", nullptr, &g_app.panel_jobs},
{"Echo", nullptr, &g_app.panel_chat},
};
static void render() {
update_fps();
// Aplicar layout pendiente (si el usuario seleccionó uno del menu Layouts).
// Debe ir antes de crear ventanas — LoadIniSettingsFromMemory afecta a las
// posiciones que se calculan a continuación.
if (g_layout_storage) {
std::string applied = fn_ui::layout_storage_apply_pending(g_layout_storage);
if (!applied.empty()) {
std::fprintf(stdout, "[graph_explorer] layout aplicado: %s\n",
applied.c_str());
}
}
// No tenemos menu propio — fn::run_app llamara al app_menubar via panels[].
if (!g_loaded) {
fullscreen_window_begin("##empty");
ImGui::TextColored(ImVec4(1, 0.7f, 0.3f, 1),
"graph_explorer — no input loaded");
ImGui::Spacing();
ImGui::TextWrapped(
"Usage: graph_explorer [<operations.db>] [--input operations <path>] "
"[--types <yaml>] [--layout <name>]");
ImGui::Spacing();
ge::views_open_modal(g_app);
if (g_app.want_open_file) {
g_input_path = g_app.open_buf;
g_app.want_open_file = false;
load_input();
}
if (fn_ui::button("Open file...", fn_ui::ButtonVariant::Primary)) {
g_app.show_open_modal = true;
}
fullscreen_window_end();
return;
}
// Dockspace host: ocupa el area BAJO la toolbar (44 px) para que las
// ventanas dockeadas no queden detras de la barra superior.
ImGuiViewport* vp = ImGui::GetMainViewport();
const float k_toolbar_h = 44.0f;
{
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, vp->WorkPos.y + k_toolbar_h));
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - k_toolbar_h));
ImGui::SetNextWindowViewport(vp->ID);
ImGuiWindowFlags hostFlags =
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking |
ImGuiWindowFlags_NoSavedSettings;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("##dock_host", nullptr, hostFlags);
ImGui::PopStyleVar();
ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0),
ImGuiDockNodeFlags_PassthruCentralNode);
ImGui::End();
}
// Toolbar superior — usa una ventana sin scroll y sin titulo
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f));
ImGui::Begin("##toolbar", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse |
ImGuiWindowFlags_NoSavedSettings);
ge::views_toolbar(g_app);
ImGui::End();
// Modals
ge::views_open_modal(g_app);
ge::views_filters_modal(g_app);
ge::views_new_project_modal(g_app);
// Project switch (desde menu Project, o tras crear proyecto nuevo)
if (g_app.want_switch_project && !g_app.switch_project_target.empty()) {
std::string target = g_app.switch_project_target;
g_app.want_switch_project = false;
g_app.switch_project_target.clear();
if (!switch_to_project(target)) {
std::fprintf(stderr,
"[graph_explorer] switch_to_project('%s') failed\n", target.c_str());
}
}
// Si el usuario aplico nuevo layout en la toolbar
if (g_app.apply_layout_tick > 0) {
apply_static_layout(g_app.layout_mode);
g_app.apply_layout_tick = 0;
}
// issue 0026 — si un job termino con cambios, dispara reload del grafo.
{
static int s_last_dirty = 0;
int d = ge::jobs_dirty_counter();
if (d != s_last_dirty) {
s_last_dirty = d;
g_app.want_reload = true;
}
}
// Chat agent — gx-cli toca .mutations.marker tras cada mutacion.
// Polleamos su mtime cada N frames; si cambia, recargamos el grafo.
// (Antes usaba un contador en agent_mutations.SQLite, pero WAL falla
// cross-NTFS<->9p cuando el .exe Windows tiene la BD abierta.)
{
static int s_last_mut = -1; // -1 = primera lectura no hecha
static int s_throttle = 0;
if (++s_throttle >= 8) {
s_throttle = 0;
int m = ge::chat_mutations_counter();
if (s_last_mut == -1) {
// Primera lectura: solo memorizar, sin disparar reload.
s_last_mut = m;
} else if (m != s_last_mut) {
ge::chat_log("mut",
"marker mtime cambio %d -> %d, disparando reload",
s_last_mut, m);
s_last_mut = m;
g_app.want_reload = true;
}
}
}
// Chat agent — drena cola de jobs encolados por gx-cli (Echo agent).
//
// Antes esta cola era una tabla `agent_jobs` en graph_explorer.db,
// pero gx-cli corre dentro de WSL y graph_explorer.exe la tiene
// abierta con WAL desde Windows. SQLite WAL falla cross-9p (mmap del
// .shm) -> "disk I/O error" al hacer INSERT desde gx-cli. Igual que
// el contador de mutaciones, lo movimos a ficheros JSON sueltos en
// <project_dir>/agent_jobs_queue/. Cada fichero = 1 job. Aqui
// escaneamos el dir, cargamos cada JSON, llamamos jobs_submit, y
// borramos el fichero (atomico via rename desde gx-cli).
if (!g_layout_db_path.empty()) {
std::filesystem::path queue_dir =
std::filesystem::path(g_layout_db_path).parent_path() /
"agent_jobs_queue";
std::error_code ec;
// Log el path una sola vez por sesion para detectar mismatches
// entre lo que escribe gx-cli y lo que escaneamos aqui.
static bool s_logged_queue_dir = false;
if (!s_logged_queue_dir) {
std::fprintf(stdout,
"[chat] agent queue scan dir: %s (exists=%d)\n",
queue_dir.string().c_str(),
std::filesystem::is_directory(queue_dir, ec) ? 1 : 0);
s_logged_queue_dir = true;
}
if (std::filesystem::is_directory(queue_dir, ec)) {
// Reusamos el sqlite ya en memoria solo para parsear JSON via
// json_extract (json1 esta enabled en el build). Sin WAL.
sqlite3* json_db = nullptr;
sqlite3_open(":memory:", &json_db);
sqlite3_stmt* parse = nullptr;
sqlite3_prepare_v2(json_db,
"SELECT json_extract(?,'$.id'), "
" json_extract(?,'$.enricher_id'), "
" json_extract(?,'$.node_id'), "
" json_extract(?,'$.node_name'), "
" json_extract(?,'$.params_json')",
-1, &parse, nullptr);
int n_processed = 0;
for (auto& ent : std::filesystem::directory_iterator(queue_dir, ec)) {
if (n_processed >= 8) break; // throttle por frame
if (!ent.is_regular_file()) continue;
auto path = ent.path();
if (path.extension() != ".json") continue;
// Leer contenido.
std::ifstream f(path, std::ios::binary);
if (!f) continue;
std::string body((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
f.close();
// Parsear via json_extract (5 binds del mismo body).
sqlite3_reset(parse);
for (int i = 1; i <= 5; ++i) {
sqlite3_bind_text(parse, i, body.c_str(), -1,
SQLITE_TRANSIENT);
}
if (sqlite3_step(parse) != SQLITE_ROW) {
std::fprintf(stderr,
"[chat] queue file %s: json_extract failed\n",
path.string().c_str());
std::filesystem::remove(path, ec);
continue;
}
auto col_str = [&](int i) -> std::string {
const unsigned char* t = sqlite3_column_text(parse, i);
return t ? (const char*)t : "";
};
std::string req_id = col_str(0);
std::string enr_id = col_str(1);
std::string node = col_str(2);
std::string nname = col_str(3);
std::string params = col_str(4);
if (params.empty()) params = "{}";
char job_id[64];
if (ge::jobs_submit(enr_id.c_str(), node.c_str(),
nname.c_str(), params.c_str(),
job_id, sizeof(job_id))) {
std::fprintf(stdout,
"[chat] queued enricher=%s node=%s as %s (req=%s)\n",
enr_id.c_str(), node.c_str(), job_id, req_id.c_str());
g_app.panel_jobs = true;
} else {
std::fprintf(stderr,
"[chat] jobs_submit failed (req=%s enricher=%s)\n",
req_id.c_str(), enr_id.c_str());
}
// Borrar el fichero independientemente de exito de submit:
// si jobs_submit fallo, reintenrar produciria duplicados.
std::filesystem::remove(path, ec);
++n_processed;
}
if (parse) sqlite3_finalize(parse);
if (json_db) sqlite3_close(json_db);
}
}
// Triggers desde la toolbar
if (g_app.want_fit) {
graph_viewport_fit(g_graph, g_viewport);
g_app.want_fit = false;
}
if (g_app.want_reload) {
g_app.want_reload = false;
// (A) Auto-save antes de liberar el grafo: preserva las posiciones
// que tenia el usuario en pantalla sin que tenga que pulsar
// "Save layout" jamas (issue 0031).
if (g_loaded && g_graph_hash != 0) {
ge::layout_store_save(g_graph_hash, g_graph);
}
graph::GraphLoadStats stats{};
if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) {
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
// Reaplica types.yaml + atlas. Sin esto, los tipos pierden
// color/shape/icon tras reload (todo nodo vuelve a circulo
// gris). Mismo flujo que reload_after_mutation.
if (!g_app.parsed_types.entities.empty() ||
!g_app.parsed_types.relations.empty()) {
std::vector<uint16_t> cps =
ge::apply_types_yaml(g_graph, g_app.parsed_types);
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(cps);
}
// Restaura posiciones guardadas para nodos preexistentes.
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
// Halo placement: junto a vecino con posicion conocida; si no
// tiene vecino (caso tipico cuando el agente crea un nodo
// aislado via MCP node_create), DENTRO de la camara visible
// con anti-colision. Convencion: world_pos == cam_pos cuando
// el nodo cae en el centro de la pantalla (graph_viewport.cpp
// L23: gx = (vx - center) / zoom + cam_x).
float cam_cx = g_viewport.cam_x;
float cam_cy = g_viewport.cam_y;
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
? g_viewport.zoom : 0.01f);
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
/*use_camera=*/true,
cam_cx, cam_cy, cam_r);
g_graph.update_bounds();
// Physics pausadas tras reload (issue 0031).
g_viewport.layout_running = false;
// Refresca el indice user_data -> sql id (puede haber nuevos
// nodos cuyo user_data no estaba en el indice anterior).
ge::entity_index_build(g_input.uri, &g_idx);
g_atlas_bound = false; // re-bind atlas tras reload
g_gpu_dirty = true;
}
}
if (g_app.want_save_layout) {
int n = ge::layout_store_save(g_graph_hash, g_graph);
std::fprintf(stdout, "[graph_explorer] saved %d node positions\n", n);
g_app.want_save_layout = false;
}
// Filtro FTS5/tags (issue 0009) — reaplica si el toolbar marco dirty.
if (g_app.filter_dirty) {
ge::views_filter_apply(g_app);
}
// Centrado del nodo seleccionado desde el dropdown.
if (g_app.filter_focus_target >= 0
&& g_app.filter_focus_target < g_graph.node_count) {
const GraphNode& n = g_graph.nodes[g_app.filter_focus_target];
g_viewport.cam_x = -n.x;
g_viewport.cam_y = -n.y;
g_app.filter_focus_target = -1;
}
if (g_app.want_open_file) {
g_input_path = g_app.open_buf;
g_app.want_open_file = false;
// Cleanup viejo grafo
graph::graph_free(&g_graph);
load_input();
}
// ---- Type Editor (issue 0007) ----
if (g_app.want_types_save) {
g_app.want_types_save = false;
g_app.types_save_error.clear();
if (g_types_path.empty()) {
g_app.types_save_error =
"No hay types.yaml asignado (abre un proyecto o usa --types).";
} else {
std::string err;
if (!ge::types_save_yaml(g_types_path.c_str(),
g_app.types_draft, &err)) {
g_app.types_save_error = "Save failed: " + err;
} else {
std::fprintf(stdout,
"[graph_explorer] types.yaml saved -> %s\n",
g_types_path.c_str());
g_app.parsed_types = g_app.types_draft;
std::vector<uint16_t> cps =
ge::apply_types_yaml(g_graph, g_app.parsed_types);
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(cps);
g_atlas_bound = false;
g_gpu_dirty = true;
g_app.types_dirty = false;
ge::views_inspector_refresh_caches(g_app);
}
}
}
if (g_app.want_types_reload) {
g_app.want_types_reload = false;
g_app.types_save_error.clear();
if (g_types_path.empty()) {
// Sin types.yaml en disco: descarta el draft a parsed_types actual.
g_app.types_draft = g_app.parsed_types;
g_app.types_dirty = false;
} else {
ge::ParsedTypes pt;
std::string err;
if (!ge::types_load_yaml(g_types_path.c_str(), &pt, &err)) {
g_app.types_save_error = "Reload failed: " + err;
} else {
std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, pt);
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(cps);
g_atlas_bound = false;
g_gpu_dirty = true;
g_app.parsed_types = pt;
g_app.types_draft = std::move(pt);
g_app.types_dirty = false;
ge::views_inspector_refresh_caches(g_app);
}
}
}
// Conteo de uso para el modal de borrado (entidades activas en BD).
if (g_app.show_te_delete_modal && g_app.te_delete_use_count == 0
&& !g_app.input_db_path.empty()) {
const char* tname = nullptr;
if (g_app.te_pending_delete_e >= 0
&& g_app.te_pending_delete_e < (int)g_app.types_draft.entities.size()) {
tname = g_app.types_draft.entities[g_app.te_pending_delete_e].name.c_str();
}
if (tname && *tname) {
sqlite3* db = nullptr;
if (sqlite3_open_v2(g_app.input_db_path.c_str(), &db,
SQLITE_OPEN_READONLY, nullptr) == SQLITE_OK) {
sqlite3_stmt* st = nullptr;
if (sqlite3_prepare_v2(db,
"SELECT COUNT(*) FROM entities WHERE type_ref = ?",
-1, &st, nullptr) == SQLITE_OK) {
sqlite3_bind_text(st, 1, tname, -1, SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
g_app.te_delete_use_count = sqlite3_column_int(st, 0);
}
sqlite3_finalize(st);
}
sqlite3_close(db);
}
}
}
// ---- Mutaciones (add/delete/duplicate/change_type) ----
auto reload_after_mutation = [&]() {
// Auto-save antes de liberar el grafo (issue 0031).
if (g_loaded && g_graph_hash != 0) {
ge::layout_store_save(g_graph_hash, g_graph);
}
graph::GraphLoadStats stats{};
if (!ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) return;
ge::entity_index_build(g_input.uri, &g_idx);
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
// Reaplica types.yaml + atlas. Sin esto, despues de cualquier
// mutacion los tipos pierden color/shape/icon (todo nodo vuelve a
// circulo gris). Issue: al promover desde node_groups el Table
// dejaba de ser cuadrado.
if (!g_app.parsed_types.entities.empty() ||
!g_app.parsed_types.relations.empty()) {
std::vector<uint16_t> cps = ge::apply_types_yaml(g_graph, g_app.parsed_types);
if (g_atlas) { graph_icons_destroy(g_atlas); g_atlas = nullptr; }
g_atlas = ge::build_icon_atlas(cps);
g_atlas_bound = false;
g_gpu_dirty = true;
}
// Refresh Table node counts (issue 0010).
ge::node_groups_refresh_counts(g_input.uri, &g_app.node_groups_counts);
// Sincroniza windows (issue 0011) por si una Table aparecio o desaparecio.
ge::views_node_groups_windows_sync(g_app, g_input.uri);
// Refresh table cache (issue 0004).
std::vector<ge::EntityRowSnapshot> snap;
if (ge::entity_list_rows(g_input.uri, &snap)) {
g_app.table_rows.clear();
g_app.table_rows.reserve(snap.size());
for (auto& s : snap) {
ge::AppState::TableRow tr;
tr.id = std::move(s.id);
tr.name = std::move(s.name);
tr.type_ref = std::move(s.type_ref);
tr.status = std::move(s.status);
tr.updated_at = std::move(s.updated_at);
tr.group_id = std::move(s.group_id);
g_app.table_rows.push_back(std::move(tr));
}
ge::views_table_refresh_indices(g_app);
}
// Restablece posiciones guardadas. Los nodos nuevos no tienen
// posicion en el layout_store y caen en (0,0).
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
// Halo placement: prefiere vecino, fallback a la camara con anti-
// colision. Los nodos nuevos aparecen DENTRO de la camara y NO
// encima de otros — el usuario los ve sin pan/zoom.
// (cam_x, cam_y) es el world point en el centro de la pantalla.
float cam_cx = g_viewport.cam_x;
float cam_cy = g_viewport.cam_y;
float cam_r = 80.0f / (g_viewport.zoom > 0.01f
? g_viewport.zoom : 0.01f);
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f,
/*use_camera=*/true,
cam_cx, cam_cy, cam_r);
g_graph.update_bounds();
g_atlas_bound = false;
g_gpu_dirty = true;
};
if (g_app.want_add_node && g_app.add_buf[0]) {
char new_id[80];
if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf,
/*type_ref=*/nullptr, new_id, sizeof(new_id))) {
std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id);
g_app.add_buf[0] = 0;
reload_after_mutation();
} else {
std::fprintf(stderr, "[graph_explorer] add_entity failed\n");
}
g_app.want_add_node = false;
}
auto ctx_id = [&]() -> const char* {
if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr;
return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data);
};
if (g_app.want_delete_node) {
if (const char* id = ctx_id()) {
if (ge::entity_delete(g_app.input_db_path.c_str(), id)) {
std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id);
reload_after_mutation();
}
}
g_app.want_delete_node = false;
g_app.ctx_node = -1;
}
if (g_app.want_duplicate_node) {
if (const char* id = ctx_id()) {
char new_id[80];
if (ge::entity_duplicate(g_app.input_db_path.c_str(), id,
new_id, sizeof(new_id))) {
std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id);
reload_after_mutation();
}
}
g_app.want_duplicate_node = false;
}
if (g_app.want_change_type && g_app.ctx_new_type[0]) {
if (const char* id = ctx_id()) {
if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) {
std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type);
reload_after_mutation();
}
}
g_app.want_change_type = false;
}
// ---- Table node UI fase 2 (issue 0011) ----
if (g_app.want_toggle_nodegroups && !g_app.toggle_nodegroups_id.empty()
&& !g_input_path.empty()) {
std::string id = g_app.toggle_nodegroups_id;
bool currently = g_app.node_groups_windows.find(id) != g_app.node_groups_windows.end();
ge::node_groups_set_expanded(g_input_path.c_str(), id.c_str(), !currently);
ge::views_node_groups_windows_sync(g_app, g_input_path.c_str());
g_app.want_toggle_nodegroups = false;
g_app.toggle_nodegroups_id.clear();
}
// 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) {
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;
}
// Refrescar la pagina si alguna window esta dirty.
for (auto& kv : g_app.node_groups_windows) {
auto& w = kv.second;
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(),
&w.total_rows);
if (!ok_count) {
char buf[512];
std::snprintf(buf, sizeof(buf),
"count failed | duckdb=%s table=%s",
m.duckdb_path_abs.c_str(), m.table_name.c_str());
w.last_error = buf;
std::fprintf(stderr, "[graph_explorer] %s\n", buf);
}
if (m.columns.empty()) {
std::vector<std::string> cols;
if (ge::node_groups_list_columns(m.duckdb_path_abs.c_str(),
m.table_name.c_str(), &cols)) {
ge::node_groups_set_columns(g_input_path.c_str(),
m.entity_id.c_str(), cols);
w.meta.columns = cols;
}
}
bool ok_page = ge::node_groups_page(m.duckdb_path_abs.c_str(),
m.table_name.c_str(), m.id_column.c_str(),
w.meta.columns,
m.filter_sql.empty() ? nullptr : m.filter_sql.c_str(),
g_input_path.c_str(), m.row_type.c_str(),
w.offset, 200, &w.page);
if (!ok_page && w.last_error.empty()) {
char buf[256];
std::snprintf(buf, sizeof(buf),
"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;
}
if (g_app.want_promote_row && !g_app.promote_table_id.empty()
&& !g_input_path.empty()) {
ge::NodeGroupsMeta m;
if (ge::node_groups_get_metadata(g_input_path.c_str(),
g_app.promote_table_id.c_str(), &m)) {
char new_id[128] = {};
if (ge::node_groups_promote_row(g_input_path.c_str(),
g_app.promote_table_id.c_str(),
m.duckdb_path_abs.c_str(),
m.table_name.c_str(),
g_app.promote_row_id.c_str(),
m.row_type.c_str(),
m.label_column.c_str(),
new_id, sizeof(new_id))) {
std::fprintf(stdout, "[promote] %s -> %s\n",
g_app.promote_row_id.c_str(), new_id);
auto it = g_app.node_groups_windows.find(g_app.promote_table_id);
if (it != g_app.node_groups_windows.end()) it->second.page_dirty = true;
reload_after_mutation();
g_app.want_focus_entity = true;
g_app.focus_entity_id = new_id;
}
}
g_app.want_promote_row = false;
g_app.promote_table_id.clear();
g_app.promote_row_id.clear();
}
if (g_app.want_demote_entity && !g_app.demote_entity_id.empty()
&& !g_input_path.empty()) {
if (ge::node_groups_demote_row(g_input_path.c_str(),
g_app.demote_entity_id.c_str())) {
std::fprintf(stdout, "[demote] %s\n", g_app.demote_entity_id.c_str());
for (auto& kv : g_app.node_groups_windows) kv.second.page_dirty = true;
reload_after_mutation();
}
g_app.want_demote_entity = false;
g_app.demote_entity_id.clear();
}
// Issue 0036d: promote out-of-group (kind=Group de NodeGroups window).
if (g_app.want_clear_group_id_entity
&& !g_app.clear_group_id_entity_id.empty()
&& !g_input_path.empty()) {
if (ge::entity_clear_group_id(g_input_path.c_str(),
g_app.clear_group_id_entity_id.c_str())) {
std::fprintf(stdout, "[node_groups] promoted %s out of group\n",
g_app.clear_group_id_entity_id.c_str());
for (auto& kv : g_app.node_groups_windows) kv.second.page_dirty = true;
reload_after_mutation();
}
g_app.want_clear_group_id_entity = false;
g_app.clear_group_id_entity_id.clear();
}
if (g_app.want_focus_entity && !g_app.focus_entity_id.empty()) {
for (int i = 0; i < g_graph.node_count; ++i) {
const char* sid = ge::entity_index_lookup(
g_idx, g_graph.nodes[i].user_data);
if (sid && g_app.focus_entity_id == sid) {
g_app.filter_focus_target = i;
graph_viewport_clear_selection(g_graph, g_viewport);
graph_viewport_add_to_selection(g_graph, g_viewport, i);
g_app.panel_inspector = true;
ge::views_inspector_load_draft(g_app, i, sid);
g_app.insp_node_idx = i;
g_app.insp_entity_id = sid;
break;
}
}
g_app.want_focus_entity = false;
g_app.focus_entity_id.clear();
}
if (g_app.want_import) {
g_app.want_import = false;
g_app.import_error.clear();
std::string duck_abs = ge::node_groups_resolve_path(
g_input_path.c_str(), g_app.import_duckdb_buf);
std::string err;
if (!ge::node_groups_ingest_file(duck_abs.c_str(),
g_app.import_path_buf,
g_app.import_table_buf,
ge::INGEST_AUTO, &err)) {
g_app.import_error = "Ingest failed: " + err;
} else {
char new_id[80] = {};
if (ge::node_groups_create(g_input_path.c_str(),
g_app.import_table_buf,
g_app.import_duckdb_buf,
g_app.import_table_buf,
g_app.import_row_type_buf,
new_id, sizeof(new_id))) {
std::fprintf(stdout, "[import] %s -> %s\n",
g_app.import_path_buf, new_id);
g_app.show_import_modal = false;
reload_after_mutation();
} else {
g_app.import_error = "Tabla DuckDB creada pero no se pudo registrar el nodo.";
}
}
}
// ---- Inspector (issue 0008): sync draft con seleccion + save/discard ----
{
const auto& sel = g_viewport.selection;
if (sel.size() == 1) {
int sidx = sel.front();
if (sidx >= 0 && sidx < g_graph.node_count
&& sidx != g_app.insp_node_idx
&& !g_app.insp_dirty) {
const char* sql_id = ge::entity_index_lookup(
g_idx, g_graph.nodes[sidx].user_data);
ge::views_inspector_load_draft(g_app, sidx, sql_id);
}
}
}
if (g_app.want_inspector_save && !g_app.insp_entity_id.empty()) {
ge::EntityRecord rec = ge::views_inspector_build_record(g_app);
if (ge::entity_update(g_app.input_db_path.c_str(), rec)) {
std::fprintf(stdout, "[graph_explorer] saved entity %s\n",
rec.id.c_str());
// Reload del grafo para que cambios de name/type/etc. se reflejen
// en el viewport (label, color del tipo, etc.).
graph::GraphLoadStats stats{};
if (ge::reload_graph(g_input, &g_graph, &stats, &g_app.group_expanded)) {
ge::entity_index_build(g_input.uri, &g_idx);
ge::views_reset_visibility(g_app);
ge::views_apply_visibility(g_app);
int restored = ge::layout_store_load(g_graph_hash, g_graph);
(void)restored;
g_atlas_bound = false;
g_gpu_dirty = true;
}
ge::views_inspector_refresh_caches(g_app);
// Re-cargar draft tras el reload (los node_idx pueden haber cambiado
// por reordenamiento de la BD). Buscamos el nuevo idx por sql_id.
int new_idx = -1;
for (int i = 0; i < g_graph.node_count; ++i) {
const char* sid = ge::entity_index_lookup(
g_idx, g_graph.nodes[i].user_data);
if (sid && rec.id == sid) { new_idx = i; break; }
}
if (new_idx >= 0) {
ge::views_inspector_load_draft(g_app, new_idx, rec.id.c_str());
graph_viewport_clear_selection(g_graph, g_viewport);
graph_viewport_add_to_selection(g_graph, g_viewport, new_idx);
} else {
ge::views_inspector_clear_draft(g_app);
}
} else {
std::fprintf(stderr, "[graph_explorer] entity_update failed for %s\n",
rec.id.c_str());
}
g_app.want_inspector_save = false;
}
if (g_app.want_inspector_discard && !g_app.insp_entity_id.empty()) {
int idx = g_app.insp_node_idx;
std::string id = g_app.insp_entity_id;
ge::views_inspector_load_draft(g_app, idx, id.c_str());
g_app.want_inspector_discard = false;
}
// Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se
// reaplica via apply_layout_tick (la toolbar ya lo incrementa).
if (g_app.want_unpin_all) {
for (int i = 0; i < g_graph.node_count; ++i) {
g_graph.nodes[i].flags &= ~NF_PINNED;
g_graph.nodes[i].vx = 0.0f;
g_graph.nodes[i].vy = 0.0f;
}
g_viewport.layout_running = true;
g_app.want_unpin_all = false;
}
// Note editor — abrir / guardar.
// Excepcion (issue 0036c): si el nodo es un Group o un Table, abrir/enfocar
// la NodeGroups window correspondiente en lugar del panel Note.
if (g_app.want_open_note && g_app.open_note_target >= 0
&& g_app.open_note_target < g_graph.node_count) {
int n = g_app.open_note_target;
const char* sql_id = ge::entity_index_lookup(g_idx, g_graph.nodes[n].user_data);
// Detectar si el nodo es de tipo Group o Table.
bool is_group = false;
bool is_table = false;
uint16_t tid = g_graph.nodes[n].type_id;
const char* type_name = (tid < (uint16_t)g_graph.type_count
&& g_graph.types[tid].name)
? g_graph.types[tid].name : "";
if (type_name && std::strcmp(type_name, "Group") == 0) is_group = true;
else if (type_name && std::strcmp(type_name, "Table") == 0) is_table = true;
if ((is_group || is_table) && sql_id) {
// Drill-in: abre/enfoca la NodeGroups window. views_node_groups_open
// crea la entry si no existe, marca focus_request=true en cualquier
// caso (el render lo consume con SetWindowFocus).
ge::NodeGroupsKind kind = is_group
? ge::NodeGroupsKind::Group
: ge::NodeGroupsKind::Table;
ge::views_node_groups_open(g_app, sql_id, kind,
g_input_path.c_str());
} else if (sql_id) {
std::string md;
ge::entity_get_notes(g_app.input_db_path.c_str(), sql_id, &md);
g_app.note_node = n;
g_app.note_entity_id = sql_id;
const char* lbl = graph::graph_label(&g_graph, g_graph.nodes[n].label_idx);
g_app.note_entity_label = lbl ? lbl : "";
g_app.note_entity_type = type_name;
// Asegura buffer >= max(64KB, contenido + holgura).
size_t need = md.size() + 4096;
if (need < 65536) need = 65536;
g_app.note_buf.assign(need, 0);
std::memcpy(g_app.note_buf.data(), md.data(), md.size());
g_app.note_dirty = false;
g_app.panel_note = true;
ImGui::SetWindowFocus(TI_FILE_TEXT " Note");
}
g_app.want_open_note = false;
g_app.open_note_target = -1;
}
if (g_app.want_save_note && !g_app.note_entity_id.empty()) {
if (ge::entity_set_notes(g_app.input_db_path.c_str(),
g_app.note_entity_id.c_str(),
g_app.note_buf.data())) {
g_app.note_dirty = false;
std::fprintf(stdout, "[graph_explorer] saved note for %s (%zu bytes)\n",
g_app.note_entity_id.c_str(),
std::strlen(g_app.note_buf.data()));
} else {
std::fprintf(stderr, "[graph_explorer] save note failed\n");
}
g_app.want_save_note = false;
}
// Posiciones iniciales razonables; el usuario puede moverlas y se
// persiste via imgui.ini.
const float top = vp->WorkPos.y + 44.0f;
const float W = vp->WorkSize.x;
const float H = vp->WorkSize.y - 44.0f;
const float lw = 240.0f; // Legend
const float rw = 320.0f; // Inspector / Stats
const float sh = H * 0.55f; // Inspector altura
// Viewport — ventana central
if (g_app.panel_viewport) {
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H), ImGuiCond_FirstUseEver);
if (ImGui::Begin("Viewport", &g_app.panel_viewport)) {
run_force_step();
GraphViewportCallbacks vp_cb{};
vp_cb.on_context_menu = &on_context_menu_cb;
vp_cb.on_double_click = &on_double_click_cb;
graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb);
render_context_menu();
// La primera vez que el viewport se dibuja, el renderer existe —
// bind del atlas (si tenemos uno).
if (!g_atlas_bound && g_viewport.renderer) {
if (g_atlas) {
graph_renderer_set_icon_atlas(g_viewport.renderer,
graph_icons_texture(g_atlas),
graph_icons_uv_table(g_atlas),
graph_icons_count(g_atlas));
}
g_atlas_bound = true;
}
if (g_app.labels_enabled) {
graph::graph_labels_draw(g_graph, g_viewport, g_label_policy,
&get_label_cb, nullptr);
}
// Table node overlay (issue 0010) — encima de las labels.
ge::views_node_groups_overlay(g_app);
}
ImGui::End();
} else {
// Sin ventana visible, igual avanzamos la simulacion para que al
// reabrirla el grafo este actualizado.
run_force_step();
}
// Legend — izquierda
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(lw, H), ImGuiCond_FirstUseEver);
ge::views_legend(g_app);
// Inspector / Stats — derecha (apilados)
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(rw, sh), ImGuiCond_FirstUseEver);
ge::views_inspector(g_app);
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver);
ge::views_stats(g_app);
// Note editor — al abrirse por primera vez se posiciona como ventana
// centrada. El usuario la puede dockear donde prefiera.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.25f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(700.0f, 480.0f), ImGuiCond_FirstUseEver);
ge::views_note(g_app);
// Type Editor (issue 0007) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(720.0f, 500.0f), ImGuiCond_FirstUseEver);
ge::views_type_editor(g_app);
ge::views_type_editor_delete_modal(g_app);
// Table view (issue 0004) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.15f, top + 60.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(820.0f, 520.0f), ImGuiCond_FirstUseEver);
ge::views_table(g_app);
// Table node windows (issue 0011) — una por Table expandida.
ge::views_node_groups_window(g_app);
ge::views_import_dataset_modal(g_app);
// Jobs panel (issue 0026) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
ge::views_jobs(g_app);
// Chat panel (claude -p) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.55f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(520.0f, 720.0f), ImGuiCond_FirstUseEver);
ge::chat_render(&g_app.panel_chat);
// Enricher config window (abierto desde context menu Run enricher).
render_enricher_config_window();
g_first_render = false;
}
// ----------------------------------------------------------------------------
// CLI parsing
// ----------------------------------------------------------------------------
static void usage() {
std::fprintf(stderr,
"Usage: graph_explorer [<operations.db>]\n"
" graph_explorer --input operations <path>\n"
" graph_explorer --types <types.yaml>\n"
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"
" graph_explorer --project <slug>\n"
" graph_explorer --test-types-yaml <path> (load+save+reload smoke test)\n"
" graph_explorer --test-duckdb <path> (open + SELECT 42 smoke test)\n"
" graph_explorer --test-tableview <path> (1M rows count + page test)\n");
}
// Smoke test del parser+writer (issue 0005 round-trip): carga `path`,
// serializa a un temporal y vuelve a cargar. Compara campos clave de
// ParsedTypes. Devuelve exit code 0 si OK, 1 si discrepancia, 2 si error.
static int test_types_yaml_roundtrip(const char* path) {
ge::ParsedTypes pt1;
std::string err;
if (!ge::types_load_yaml(path, &pt1, &err)) {
std::fprintf(stderr, "[test] load1 fail: %s\n", err.c_str());
return 2;
}
std::string tmp = std::string(path) + ".roundtrip.yaml";
if (!ge::types_save_yaml(tmp.c_str(), pt1, &err)) {
std::fprintf(stderr, "[test] save fail: %s\n", err.c_str());
return 2;
}
ge::ParsedTypes pt2;
if (!ge::types_load_yaml(tmp.c_str(), &pt2, &err)) {
std::fprintf(stderr, "[test] load2 fail: %s\n", err.c_str());
return 2;
}
auto cmp = [&]() -> bool {
if (pt1.entities.size() != pt2.entities.size()) return false;
if (pt1.relations.size() != pt2.relations.size()) return false;
for (size_t i = 0; i < pt1.entities.size(); ++i) {
const auto& a = pt1.entities[i];
const auto& b = pt2.entities[i];
if (a.name != b.name) return false;
if (a.color != b.color) return false;
if (a.icon_name != b.icon_name) return false;
if (a.principal_field != b.principal_field) return false;
if (a.fields.size() != b.fields.size()) return false;
for (size_t j = 0; j < a.fields.size(); ++j) {
const auto& fa = a.fields[j];
const auto& fb = b.fields[j];
if (fa.name != fb.name) return false;
if (fa.kind != fb.kind) return false;
if (fa.required != fb.required) return false;
if (fa.enum_values != fb.enum_values) return false;
}
}
for (size_t i = 0; i < pt1.relations.size(); ++i) {
const auto& a = pt1.relations[i];
const auto& b = pt2.relations[i];
if (a.name != b.name) return false;
if (a.color != b.color) return false;
if (a.style != b.style) return false;
}
return true;
};
int total_fields = 0;
for (const auto& e : pt1.entities) total_fields += (int)e.fields.size();
if (cmp()) {
std::fprintf(stdout,
"[test] PASS — %zu entities, %d fields, %zu relations (round-trip estable)\n",
pt1.entities.size(), total_fields, pt1.relations.size());
std::remove(tmp.c_str());
return 0;
}
std::fprintf(stderr,
"[test] FAIL — discrepancia tras round-trip. dump preservado en %s\n",
tmp.c_str());
return 1;
}
int main(int argc, char** argv) {
bool legacy_mode = false; // --input / positional dado: NO usar proyecto
std::string project_arg; // --project <slug> (puede estar vacio)
for (int i = 1; i < argc; ++i) {
const char* a = argv[i];
if (std::strcmp(a, "--input") == 0 && i + 2 < argc) {
const char* kind = argv[++i];
const char* path = argv[++i];
if (std::strcmp(kind, "operations") == 0) {
g_input_path = path;
legacy_mode = true;
} else {
std::fprintf(stderr, "[graph_explorer] unsupported input kind: %s\n", kind);
return 1;
}
} else if (std::strcmp(a, "--types") == 0 && i + 1 < argc) {
g_types_path = argv[++i];
} else if (std::strcmp(a, "--layout") == 0 && i + 1 < argc) {
g_layout_initial = argv[++i];
} else if (std::strcmp(a, "--project") == 0 && i + 1 < argc) {
project_arg = argv[++i];
} else if (std::strcmp(a, "--test-types-yaml") == 0 && i + 1 < argc) {
return test_types_yaml_roundtrip(argv[++i]);
} else if (std::strcmp(a, "--test-duckdb") == 0 && i + 1 < argc) {
const char* p = argv[++i];
if (!ge::node_groups_smoke_test(p)) {
std::fprintf(stderr, "[duckdb] smoke test FAILED for %s\n", p);
return 2;
}
std::fprintf(stdout, "[duckdb] smoke test OK (SELECT 42 -> 42) on %s\n", p);
return 0;
} else if (std::strcmp(a, "--test-tableview") == 0 && i + 1 < argc) {
// Crea 1M filas en duckdb_path/people, cuenta y pagina.
const char* p = argv[++i];
std::remove(p); // empezar desde cero
duckdb_database db = nullptr;
duckdb_connection cn = nullptr;
if (duckdb_open(p, &db) == DuckDBError) { std::fprintf(stderr, "open fail\n"); return 2; }
duckdb_connect(db, &cn);
duckdb_result r;
if (duckdb_query(cn,
"CREATE TABLE people AS "
"SELECT range AS id, 'name_' || CAST(range AS VARCHAR) AS name, "
" (range * 7) % 100 AS age FROM range(1000000)", &r) == DuckDBError) {
std::fprintf(stderr, "create fail: %s\n",
duckdb_result_error(&r) ? duckdb_result_error(&r) : "?");
duckdb_destroy_result(&r);
duckdb_disconnect(&cn); duckdb_close(&db); return 2;
}
duckdb_destroy_result(&r);
duckdb_disconnect(&cn); duckdb_close(&db);
int64_t total = 0;
if (!ge::node_groups_count(p, "people", nullptr, &total) || total != 1000000) {
std::fprintf(stderr, "[node_groups_count] expected 1000000, got %lld\n",
(long long)total);
return 2;
}
std::vector<std::string> cols = { "name", "age" };
std::vector<ge::NodeGroupsRow> page;
if (!ge::node_groups_page(p, "people", "id", cols, nullptr,
nullptr, nullptr, 500000, 10, &page)) {
std::fprintf(stderr, "[node_groups_page] failed\n");
return 2;
}
if (page.size() != 10) {
std::fprintf(stderr, "[node_groups_page] expected 10 rows, got %zu\n",
page.size());
return 2;
}
std::fprintf(stdout,
"[node_groups] OK — count=%lld, page[0]={id=%s, name=%s, age=%s}\n",
(long long)total, page[0].id.c_str(),
page[0].values.size() > 0 ? page[0].values[0].c_str() : "",
page[0].values.size() > 1 ? page[0].values[1].c_str() : "");
return 0;
} else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
usage();
return 0;
} else if (a[0] == '-') {
std::fprintf(stderr, "[graph_explorer] unknown flag: %s\n", a);
usage();
return 1;
} else {
// Positional: tratado como operations.db (legacy)
if (g_input_path.empty()) {
g_input_path = a;
legacy_mode = true;
}
}
}
if (legacy_mode) {
// Modo legacy: paths sueltos en local_files/ (graph_explorer.db
// como fallback cuando no se ha cargado un proyecto).
std::string legacy_db = fn::local_path("graph_explorer.db");
ge::layout_store_open(legacy_db.c_str());
g_layout_db_path = legacy_db;
if (!g_input_path.empty()) {
load_input();
}
panel_state_load_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
} else {
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
// crea default si no existe ninguno.
ge::projects_migrate_legacy_layout();
std::string target = project_arg;
if (target.empty()) {
ge::ProjectSettings ps;
ge::project_settings_load(&ps);
target = ps.last_active;
}
if (target.empty()) target = ge::k_default_project;
if (!ge::project_exists(target.c_str())) {
std::string err;
if (!ge::project_create(target.c_str(), &err)) {
std::fprintf(stderr,
"[graph_explorer] no se pudo crear el proyecto '%s': %s\n",
target.c_str(), err.c_str());
return 1;
}
std::fprintf(stdout,
"[graph_explorer] proyecto creado: projects/%s/\n", target.c_str());
}
apply_project_paths(target);
// Migracion idempotente del schema (issue 0035a y siguientes).
{
std::string mig_err;
if (!ge::project_migrate_schema(g_input_path, &mig_err)) {
std::fprintf(stderr,
"[graph_explorer] project_migrate_schema('%s') failed: %s\n",
g_input_path.c_str(), mig_err.c_str());
}
}
ge::layout_store_open(g_layout_db_path.c_str());
ge::project_settings_touch(target.c_str());
load_input();
panel_state_load_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
}
fn_ui::about_window_set_info(
"graph_explorer",
"0.1.0",
"Visor de grafos GPU-accelerated agnostico del backend. Lee operations.db de "
"cualquier app del registry y permite explorar entidades/relaciones con "
"shapes/iconos/layouts/filtros.");
// issue 0026 — sistema de jobs + enrichers.
{
std::string registry_root = resolve_registry_root();
std::string app_dir = registry_root.empty()
? "."
: registry_root + "/projects/osint_graph/apps/graph_explorer";
// Convencion assets/: enrichers vienen empaquetados en
// <exe_dir>/assets/enrichers/. Fallback al app_dir del repo
// para modo dev local cuando se ejecuta desde build/.
std::string enrichers_dir;
{
std::string assets_enrichers = fn::asset_path("enrichers");
struct stat st{};
if (::stat(assets_enrichers.c_str(), &st) == 0 &&
S_ISDIR(st.st_mode)) {
enrichers_dir = assets_enrichers;
} else {
enrichers_dir = app_dir + "/enrichers";
}
}
// graph_explorer.db es el mismo SQLite usado por layout_store.
// Default a <local_files>/graph_explorer.db si no hay proyecto.
std::string fallback_db = fn::local_path("graph_explorer.db");
const char* app_db = g_layout_db_path.empty()
? fallback_db.c_str() : g_layout_db_path.c_str();
// Layout storage — guardado/cargado de layouts ImGui en
// graph_explorer.db. El menu Layouts del menubar consume estos cb.
if (g_layout_db_path.empty()) {
std::fprintf(stderr,
"[graph_explorer] layout storage skipped (no db_path)\n");
} else {
g_layout_storage = fn_ui::layout_storage_open(
g_layout_db_path.c_str());
if (g_layout_storage) {
fn_ui::layout_storage_make_callbacks(
g_layout_storage, g_layout_cb);
std::fprintf(stdout,
"[graph_explorer] layout storage abierto en %s\n",
g_layout_db_path.c_str());
} else {
std::fprintf(stderr,
"[graph_explorer] layout_storage_open fallo: %s\n",
g_layout_db_path.c_str());
}
}
ge::enrichers_load(enrichers_dir.c_str());
if (!ge::jobs_init(app_db,
g_input.uri ? g_input.uri : "",
enrichers_dir.c_str(),
app_dir.c_str(),
registry_root.c_str(),
/*n_workers=*/2)) {
std::fprintf(stderr, "[graph_explorer] jobs_init failed (panel disabled)\n");
} else {
std::fprintf(stdout,
"[graph_explorer] jobs_init OK — enrichers_dir=%s, registry_root=%s, %d enrichers\n",
enrichers_dir.c_str(), registry_root.c_str(),
(int)ge::enrichers_all().size());
}
// Chat panel (claude -p) — el agente invoca gx-cli para mutar
// operations.db. agent_mutations counter en graph_explorer.db dispara
// reload del viewport en cada cambio.
if (!ge::chat_init(g_input.uri ? g_input.uri : "",
app_db, app_dir.c_str())) {
std::fprintf(stderr,
"[graph_explorer] chat_init: claude no detectado "
"(panel Chat deshabilitado)\n");
}
}
// 0036f — accion en el menu View que abre la NodeGroups window del
// nodo seleccionado. Disabled si la seleccion no es Table ni Group.
auto view_extras_cb = []() -> bool {
// Resolver seleccion actual: priorizamos el nodo del viewport;
// si no hay nada seleccionado en el canvas pero el inspector
// tiene una entidad cargada, usamos esa.
int sel_idx = -1;
if (!g_viewport.selection.empty()) {
int s = g_viewport.selection.front();
if (s >= 0 && s < g_graph.node_count) sel_idx = s;
}
if (sel_idx < 0
&& g_app.insp_node_idx >= 0
&& g_app.insp_node_idx < g_graph.node_count) {
sel_idx = g_app.insp_node_idx;
}
bool is_table = false;
bool is_group = false;
const char* sql_id = nullptr;
if (sel_idx >= 0) {
uint16_t tid = g_graph.nodes[sel_idx].type_id;
const char* tn = (tid < (uint16_t)g_graph.type_count
&& g_graph.types[tid].name)
? g_graph.types[tid].name : "";
if (tn && std::strcmp(tn, "Table") == 0) is_table = true;
else if (tn && std::strcmp(tn, "Group") == 0) is_group = true;
sql_id = ge::entity_index_lookup(
g_idx, g_graph.nodes[sel_idx].user_data);
}
const bool enabled = (is_table || is_group) && sql_id != nullptr;
ImGui::Separator();
bool acted = false;
if (!enabled) ImGui::BeginDisabled();
if (ImGui::MenuItem(TI_FOLDER_OPEN " Open NodeGroups for selected")) {
if (enabled) {
ge::NodeGroupsKind kind = is_group
? ge::NodeGroupsKind::Group
: ge::NodeGroupsKind::Table;
ge::views_node_groups_open(g_app, sql_id, kind,
g_input_path.c_str());
acted = true;
}
}
if (!enabled) ImGui::EndDisabled();
if (!enabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip("Select a Table or Group node first");
}
return acted;
};
int rc = fn::run_app(
{.title = "graph_explorer",
.width = 1600,
.height = 1000,
.viewports = true,
.panels = g_panels,
.panel_count = sizeof(g_panels) / sizeof(g_panels[0]),
.layouts_cb = g_layout_storage ? &g_layout_cb : nullptr,
.view_extras = view_extras_cb,
.init_gl_loader = true},
render);
// Auto-save de posiciones de nodos al salir — sin esto las posiciones se
// pierden si el usuario nunca presiona "Save layout" (issue 0031 + nudge).
if (g_loaded && g_graph_hash != 0) {
int n = ge::layout_store_save(g_graph_hash, g_graph);
std::fprintf(stdout,
"[graph_explorer] auto-saved %d node positions on exit\n", n);
}
// Auto-save de paneles abiertos/cerrados al salir.
panel_state_save_db(g_layout_db_path, g_panels,
sizeof(g_panels) / sizeof(g_panels[0]));
// Cleanup
ge::chat_shutdown();
ge::jobs_shutdown();
if (g_layout_storage) {
fn_ui::layout_storage_close(g_layout_storage);
g_layout_storage = nullptr;
}
if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
if (g_atlas) graph_icons_destroy(g_atlas);
graph_viewport_destroy(g_viewport);
graph::graph_free(&g_graph);
ge::layout_store_close();
return rc;
}