#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 #include #include #include #include #include #include #include #include #include #include #include #ifndef _WIN32 #include #else #include #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> orphans_by_anchor; std::vector 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) ----- // Para cada anchor con sus hijos, los repartimos en un anillo // alrededor del padre. Si hay mas hijos de los que caben en el // anillo base, abrimos anillos adicionales. Cada hijo sigue // pasando find_collision_free_slot como fallback si el slot ideal // estaba ocupado por otro nodo del grafo. const float two_pi = 6.28318530718f; for (auto& kv : orphans_by_anchor) { int parent = kv.first; std::vector& 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; // Capacidad por anillo: circunferencia / min_dist. // Para min_dist=60, ring r=80 -> ~8 slots; r=140 -> ~14. for (size_t k = 0; k < kids.size(); ++k) { // Anillo y slot dentro del anillo en funcion del indice. int ri = 0; size_t accum = 0; size_t cap = 0; for (; ri < n_neighbor_radii; ++ri) { float r_here = neighbor_radii[ri]; cap = (size_t)std::max(6.0f, two_pi * r_here / min_dist); if (k < accum + cap) break; accum += cap; } if (ri >= n_neighbor_radii) ri = n_neighbor_radii - 1; float r_use = neighbor_radii[ri]; cap = (size_t)std::max(6.0f, two_pi * r_use / min_dist); size_t slot = k - accum; // Jitter pequeno por user_data para que rondas distintas no // queden alineadas si comparten anchor. uint64_t seed = g.nodes[kids[k]].user_data; float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * (two_pi / cap); float angle = jitter + (float)slot * (two_pi / cap); float px = cx + r_use * std::cos(angle); float py = cy + r_use * std::sin(angle); // Si el slot ideal colisiona con un nodo ajeno al cluster, // delegamos en find_collision_free_slot que probara mas // angulos en radios crecientes. GraphNode& kid = g.nodes[kids[k]]; if (layout_no_collision(g, kids[k], px, py, min_dist)) { kid.x = px; kid.y = py; } else { float ox, oy; if (find_collision_free_slot( g, kids[k], cx, cy, min_dist, seed, neighbor_radii, n_neighbor_radii, &ox, &oy)) { kid.x = ox; kid.y = oy; } else { kid.x = px; kid.y = py; // ultimo recurso: solape } } 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\\` // 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\\\\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 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 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(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 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 [] [--input operations ] " "[--types ] [--layout ]"); 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 // /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(f)), std::istreambuf_iterator()); 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 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 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 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 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 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 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(); } 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 0035d): si el nodo es un Group, en lugar de abrir // Note se abre el panel Table con filtro por group_id. 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. bool is_group = 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; if (is_group && sql_id) { // Drill-in: abrir Table panel filtrado por group_id = sql_id. g_app.table_filter_group_id = sql_id; const char* lbl = graph::graph_label(&g_graph, g_graph.nodes[n].label_idx); g_app.table_filter_group_name = lbl ? lbl : sql_id; // Reset filtros que pueden ocultar las filas del grupo. g_app.table_search_buf[0] = 0; g_app.table_col_filters.clear(); g_app.table_show_all = true; g_app.panel_table = true; ImGui::SetWindowFocus("Table"); } 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 []\n" " graph_explorer --input operations \n" " graph_explorer --types \n" " graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n" " graph_explorer --project \n" " graph_explorer --test-types-yaml (load+save+reload smoke test)\n" " graph_explorer --test-duckdb (open + SELECT 42 smoke test)\n" " graph_explorer --test-tableview (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 (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 cols = { "name", "age" }; std::vector 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 // /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 /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"); } } 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, .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; }