From 1065e184cfb90c3d8451115ee3605fe2c8a6254c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:52:49 +0200 Subject: [PATCH 1/4] feat(tableview): helpers fase 2 (issue 0011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableMetadata struct + tableview_get_metadata: lee la metadata de un nodo Table (path, table, row_type, columns, label_column, expanded...). - tableview_set_expanded: persiste el flag expanded usando json_set. - tableview_set_columns: sobrescribe metadata.columns. - tableview_promote_row: idempotente — si ya existe entidad con metadata.source.row_id == row_id la devuelve; si no, lee fila completa desde DuckDB e inserta entity con id 'prom__' y metadata incluyendo source + columnas. - tableview_demote_row: DELETE FROM entities (la fila DuckDB no se toca). - tableview_ingest_file: CREATE TABLE AS SELECT * FROM read_csv_auto/ read_parquet/read_json_auto segun extension del input. - tableview_list_columns: SELECT * FROM tabla LIMIT 0 -> nombres. --- .../0011-tablenode-expanded-promote.md | 0 tableview.cpp | 414 ++++++++++++++++++ tableview.h | 78 ++++ 3 files changed, 492 insertions(+) rename issues/{ => completed}/0011-tablenode-expanded-promote.md (100%) diff --git a/issues/0011-tablenode-expanded-promote.md b/issues/completed/0011-tablenode-expanded-promote.md similarity index 100% rename from issues/0011-tablenode-expanded-promote.md rename to issues/completed/0011-tablenode-expanded-promote.md diff --git a/tableview.cpp b/tableview.cpp index 2ebcdfd..35723f3 100644 --- a/tableview.cpp +++ b/tableview.cpp @@ -354,4 +354,418 @@ bool tableview_refresh_counts(const char* ops_db, return true; } +// ---------------------------------------------------------------------------- +// Issue 0011 — UI fase 2 helpers +// ---------------------------------------------------------------------------- + +namespace { + +std::string sanitize_id_part(const char* s) { + std::string out; + if (!s) return out; + for (const char* p = s; *p && out.size() < 60; ++p) { + char c = *p; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '_') out += c; + else out += '_'; + } + return out; +} + +// Parser minimo para extraer un array de strings de un JSON tipo +// "[\"a\",\"b\",\"c\"]". No es un JSON parser general — se usa solo sobre +// el campo `columns` que escribe esta misma capa, asi que el formato esta +// controlado. +std::vector parse_json_string_array(const char* s) { + std::vector out; + if (!s) return out; + const char* p = s; + while (*p && *p != '[') ++p; + if (*p != '[') return out; + ++p; + while (*p) { + while (*p && (*p == ' ' || *p == ',' || *p == '\t' || *p == '\n')) ++p; + if (*p == ']' || !*p) break; + if (*p != '"') { ++p; continue; } + ++p; + std::string v; + while (*p && *p != '"') { + if (*p == '\\' && p[1]) { v += p[1]; p += 2; } + else { v += *p++; } + } + if (*p == '"') ++p; + out.push_back(std::move(v)); + } + return out; +} + +} // namespace + +bool tableview_list_columns(const char* duckdb_path, + const char* duck_table, + std::vector* out) +{ + if (!duckdb_path || !duck_table || !out) return false; + out->clear(); + DuckHandle h; + if (!h.open(duckdb_path)) return false; + std::string sql = "SELECT * FROM " + sanitize_ident(duck_table) + " LIMIT 0"; + duckdb_result r; + if (duckdb_query(h.cn, sql.c_str(), &r) == DuckDBError) { + duckdb_destroy_result(&r); + return false; + } + idx_t cn = duckdb_column_count(&r); + for (idx_t i = 0; i < cn; ++i) { + const char* name = duckdb_column_name(&r, i); + if (name) out->emplace_back(name); + } + duckdb_destroy_result(&r); + return true; +} + +bool tableview_get_metadata(const char* ops_db, const char* entity_id, + TableMetadata* out) +{ + if (!ops_db || !entity_id || !out) return false; + *out = TableMetadata{}; + out->entity_id = entity_id; + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = + "SELECT name, " + " json_extract(metadata, '$.duckdb_path'), " + " json_extract(metadata, '$.table_name'), " + " json_extract(metadata, '$.row_type'), " + " json_extract(metadata, '$.id_column'), " + " json_extract(metadata, '$.label_column'), " + " json_extract(metadata, '$.columns'), " + " json_extract(metadata, '$.filter_sql'), " + " json_extract(metadata, '$.expanded') " + "FROM entities WHERE id = ? AND type_ref = 'Table'"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id, -1, SQLITE_TRANSIENT); + bool ok = false; + if (sqlite3_step(st) == SQLITE_ROW) { + auto col_text = [&](int i) -> std::string { + const unsigned char* p = sqlite3_column_text(st, i); + return p ? std::string((const char*)p) : std::string(); + }; + out->name = col_text(0); + out->duckdb_path = col_text(1); + out->table_name = col_text(2); + out->row_type = col_text(3); + out->id_column = col_text(4); + out->label_column = col_text(5); + std::string cols_json = col_text(6); + out->filter_sql = col_text(7); + std::string exp_json = col_text(8); + + if (out->id_column.empty()) out->id_column = "id"; + if (out->label_column.empty()) out->label_column = "name"; + out->columns = parse_json_string_array(cols_json.c_str()); + out->expanded = (exp_json == "true" || exp_json == "1"); + out->duckdb_path_abs = tableview_resolve_path(ops_db, out->duckdb_path.c_str()); + ok = true; + } + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +namespace { + +bool exec_metadata_patch(const char* ops_db, const char* entity_id, + const char* json_set_clause) +{ + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + std::string sql = "UPDATE entities SET metadata = "; + sql += json_set_clause; + sql += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql.c_str(), -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id, -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +} // namespace + +bool tableview_set_expanded(const char* ops_db, const char* entity_id, + bool expanded) +{ + if (!ops_db || !entity_id) return false; + std::string clause = "json_set(COALESCE(metadata,'{}'), '$.expanded', json('"; + clause += expanded ? "true" : "false"; + clause += "'))"; + return exec_metadata_patch(ops_db, entity_id, clause.c_str()); +} + +bool tableview_set_columns(const char* ops_db, const char* entity_id, + const std::vector& columns) +{ + if (!ops_db || !entity_id) return false; + std::string arr = "["; + for (size_t i = 0; i < columns.size(); ++i) { + if (i) arr += ","; + arr += "\""; + arr += sql_escape(columns[i].c_str()); + arr += "\""; + } + arr += "]"; + std::string clause = "json_set(COALESCE(metadata,'{}'), '$.columns', json('"; + clause += sql_escape(arr.c_str()); + clause += "'))"; + return exec_metadata_patch(ops_db, entity_id, clause.c_str()); +} + +namespace { + +// Busca si ya existe una entidad promovida por (duckdb_path, row_id). +bool find_existing_promotion(const char* ops_db, const char* duckdb_path, + const char* row_id, std::string* out_id) +{ + if (!ops_db || !duckdb_path || !row_id || !out_id) return false; + out_id->clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = + "SELECT id FROM entities " + "WHERE json_extract(metadata, '$.source.duckdb') = ? " + " AND json_extract(metadata, '$.source.row_id') = ? " + "LIMIT 1"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, duckdb_path, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, row_id, -1, SQLITE_TRANSIENT); + bool found = false; + if (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (p) { *out_id = (const char*)p; found = true; } + } + sqlite3_finalize(st); + sqlite3_close(db); + return found; +} + +} // namespace + +bool tableview_promote_row(const char* ops_db, + const char* duckdb_path, + const char* duck_table, + const char* row_id, + const char* row_type, + const char* label_column, + char* out_entity_id, std::size_t out_id_n) +{ + if (!ops_db || !duckdb_path || !duck_table || !row_id) return false; + if (!row_type || !*row_type) row_type = "Row"; + if (!label_column || !*label_column) label_column = "name"; + + // Idempotencia: si ya esta promovida, devuelve el id existente. + { + std::string existing; + if (find_existing_promotion(ops_db, duckdb_path, row_id, &existing)) { + if (out_entity_id && out_id_n > 0) { + std::snprintf(out_entity_id, out_id_n, "%s", existing.c_str()); + } + return true; + } + } + + // Lee la fila + label desde DuckDB. + DuckHandle h; + if (!h.open(duckdb_path)) return false; + std::string tn = sanitize_ident(duck_table); + std::string idc = "id"; // por convencion. Si la tabla usa otro PK, el caller lo sabe. + { + // Best-effort: si label_column existe en la tabla, lo seleccionamos. + std::string sel = "SELECT * FROM " + tn + " WHERE CAST(" + idc + " AS VARCHAR) = '" + + sql_escape(row_id) + "' LIMIT 1"; + duckdb_result r; + if (duckdb_query(h.cn, sel.c_str(), &r) == DuckDBError) { + std::fprintf(stderr, "[promote] query fail: %s\n", + duckdb_result_error(&r) ? duckdb_result_error(&r) : "?"); + duckdb_destroy_result(&r); + return false; + } + if (duckdb_row_count(&r) == 0) { + duckdb_destroy_result(&r); + return false; + } + // Construir name = valor del label_column si existe; fallback row_id. + std::string display_name = row_id; + idx_t ncols = duckdb_column_count(&r); + for (idx_t i = 0; i < ncols; ++i) { + const char* cname = duckdb_column_name(&r, i); + if (cname && std::strcmp(cname, label_column) == 0) { + if (!duckdb_value_is_null(&r, i, 0)) { + char* v = duckdb_value_varchar(&r, i, 0); + if (v) { display_name = v; duckdb_free(v); } + } + break; + } + } + + // Construir metadata JSON con source + columnas. + std::string meta = "{\"source\":{"; + meta += "\"duckdb\":\""; meta += sql_escape(duckdb_path); meta += "\","; + meta += "\"table\":\""; meta += sql_escape(duck_table); meta += "\","; + meta += "\"row_id\":\""; meta += sql_escape(row_id); meta += "\""; + meta += "}"; + for (idx_t i = 0; i < ncols; ++i) { + const char* cname = duckdb_column_name(&r, i); + if (!cname || !*cname) continue; + meta += ",\""; + meta += sql_escape(cname); + meta += "\":"; + if (duckdb_value_is_null(&r, i, 0)) { + meta += "null"; + } else { + char* v = duckdb_value_varchar(&r, i, 0); + meta += "\""; + if (v) { meta += sql_escape(v); duckdb_free(v); } + meta += "\""; + } + } + meta += "}"; + duckdb_destroy_result(&r); + + // Genera id estable. + std::string entity_id = "prom_"; + entity_id += sanitize_id_part(row_type); + entity_id += "_"; + entity_id += sanitize_id_part(row_id); + + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* ins = + "INSERT OR IGNORE INTO entities(" + " id, name, type_ref, status, tags, source, metadata, " + " created_at, updated_at) " + "VALUES (?, ?, ?, 'active', '[]', 'tableview', ?, " + " strftime('%Y-%m-%dT%H:%M:%fZ','now'), " + " strftime('%Y-%m-%dT%H:%M:%fZ','now'))"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, ins, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, display_name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, row_type, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, meta.c_str(), -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + if (ok && out_entity_id && out_id_n > 0) { + std::snprintf(out_entity_id, out_id_n, "%s", entity_id.c_str()); + } + return ok; + } +} + +bool tableview_demote_row(const char* ops_db, const char* entity_id) { + if (!ops_db || !entity_id) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "DELETE FROM entities WHERE id = ?", + -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, entity_id, -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + sqlite3_close(db); + return ok; +} + +namespace { + +IngestKind detect_ingest_kind(const char* path) { + if (!path) return INGEST_CSV; + const char* dot = std::strrchr(path, '.'); + if (!dot) return INGEST_CSV; + if (std::strcmp(dot, ".csv") == 0) return INGEST_CSV; + if (std::strcmp(dot, ".tsv") == 0) return INGEST_CSV; + if (std::strcmp(dot, ".parquet") == 0) return INGEST_PARQUET; + if (std::strcmp(dot, ".pq") == 0) return INGEST_PARQUET; + if (std::strcmp(dot, ".json") == 0) return INGEST_JSON; + if (std::strcmp(dot, ".ndjson") == 0) return INGEST_JSON; + return INGEST_CSV; +} + +const char* ingest_func_name(IngestKind k) { + switch (k) { + case INGEST_PARQUET: return "read_parquet"; + case INGEST_JSON: return "read_json_auto"; + default: return "read_csv_auto"; + } +} + +} // namespace + +bool tableview_ingest_file(const char* duckdb_path, + const char* file_path, + const char* dest_table, + IngestKind kind, + std::string* out_error) +{ + if (!duckdb_path || !file_path || !dest_table) { + if (out_error) *out_error = "args nulos"; + return false; + } + if (kind == INGEST_AUTO) kind = detect_ingest_kind(file_path); + + DuckHandle h; + if (!h.open(duckdb_path)) { + if (out_error) *out_error = "no se pudo abrir el .duckdb"; + return false; + } + std::string tn = sanitize_ident(dest_table); + std::string sql = "CREATE TABLE " + tn + " AS SELECT * FROM " + + ingest_func_name(kind) + "('" + sql_escape(file_path) + "')"; + duckdb_result r; + if (duckdb_query(h.cn, sql.c_str(), &r) == DuckDBError) { + if (out_error) { + const char* err = duckdb_result_error(&r); + *out_error = err ? err : "ingest fail"; + } + duckdb_destroy_result(&r); + return false; + } + duckdb_destroy_result(&r); + return true; +} + } // namespace ge diff --git a/tableview.h b/tableview.h index d81a79b..2b3fa03 100644 --- a/tableview.h +++ b/tableview.h @@ -76,4 +76,82 @@ struct TableCounts { bool tableview_refresh_counts(const char* ops_db, std::unordered_map* out); +// ---------------------------------------------------------------------------- +// Issue 0011 — UI fase 2 helpers +// ---------------------------------------------------------------------------- + +// Snapshot de la metadata relevante de un nodo Table. Caller-owned strings. +struct TableMetadata { + std::string entity_id; + std::string name; + std::string duckdb_path; // tal como aparece en metadata (relativo o absoluto) + std::string duckdb_path_abs; // resuelto con tableview_resolve_path + std::string table_name; + std::string row_type; + std::string id_column; + std::string label_column; + std::vector columns; + std::string filter_sql; + bool expanded = false; +}; + +// Lee la metadata del nodo Table (entidad type_ref='Table' con id=`entity_id`). +// Devuelve false si no existe o falla el JSON. Aplica defaults razonables a +// los campos faltantes (id_column='id', label_column='name'). +bool tableview_get_metadata(const char* ops_db, const char* entity_id, + TableMetadata* out); + +// Persiste el flag `expanded` en la metadata. Idempotente. Devuelve false +// en error de IO/SQL. +bool tableview_set_expanded(const char* ops_db, const char* entity_id, + bool expanded); + +// Sobrescribe el array `columns` en la metadata. Llamar tras editar columnas +// desde la UI o tras tableview_create cuando se descubren columnas. +bool tableview_set_columns(const char* ops_db, const char* entity_id, + const std::vector& columns); + +// Promueve una fila de DuckDB a entidad del grafo. Idempotente: si ya existe +// una entidad con metadata.source.row_id == row_id Y metadata.source.duckdb +// == duckdb_path, devuelve esa misma id en out_id sin tocar nada. +// +// Si no existe, lee la fila de DuckDB con SELECT *, construye un id estable +// "prom__", e inserta en ops.entities +// con type_ref=row_type, name = valor del label_column (o row_id si vacio), +// metadata = { source: {duckdb, table, row_id}, }. +bool tableview_promote_row(const char* ops_db, + const char* duckdb_path, + const char* duck_table, + const char* row_id, + const char* row_type, + const char* label_column, + char* out_entity_id, std::size_t out_id_n); + +// Borra la entidad. La fila DuckDB sigue intacta. Devuelve true si la fila +// no existia (no-op idempotente). +bool tableview_demote_row(const char* ops_db, const char* entity_id); + +// Tipos de fichero soportados por tableview_ingest_file. +enum IngestKind { + INGEST_AUTO = 0, // detecta por extension + INGEST_CSV = 1, + INGEST_PARQUET = 2, + INGEST_JSON = 3, +}; + +// Importa un fichero CSV/Parquet/JSON al `.duckdb`. Crea el .duckdb si no +// existe. Si la tabla destino existe, falla (no sobrescribe — explicit fail). +// Por defecto INGEST_AUTO inspecciona la extension del path. +bool tableview_ingest_file(const char* duckdb_path, + const char* file_path, + const char* dest_table, + IngestKind kind, + std::string* out_error); + +// Lista los nombres de columna de la tabla DuckDB. Para popular la lista +// `columns` por defecto en tableview_create. +bool tableview_list_columns(const char* duckdb_path, + const char* duck_table, + std::vector* out); + } // namespace ge From cedfe3b61666aacb44bfbaf424dc188b4835790f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:53:02 +0200 Subject: [PATCH 2/4] feat(views): ventana Table expandida + import modal (issue 0011) - AppState::TableWindowState: estado runtime por Table expandida (meta, total_rows, offset, page cache, dirty, open). Mapa por entity_id. - views_table_windows_sync: lee operations.db buscando Tables con metadata.expanded=true y crea/refresca/borra TableWindowState. Llamar tras load + reload_after_mutation. - views_table_window: ImGui::Begin dockeable por Table expandida con cabecera de columnas, BeginTable + filas paginadas (200/pagina) + indicador 'promoted'. Doble-click promueve fila no promovida; en promovida abre Inspector. Right-click context menu por fila con Promote/Demote/Focus. - views_import_dataset_modal: formulario File path + DuckDB path + Dest table + Row type. Trigger want_import. - Toolbar 'Import dataset...' button. - Triggers en AppState: want_promote_row, want_demote_entity, want_focus_entity, want_toggle_expanded, want_import. --- views.cpp | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ views.h | 59 +++++++++++++ 2 files changed, 310 insertions(+) diff --git a/views.cpp b/views.cpp index 7aedb7b..48f50b2 100644 --- a/views.cpp +++ b/views.cpp @@ -6,6 +6,8 @@ #include "viz/graph_viewport.h" #include "viz/graph_sources.h" +#include "../../../../cpp/vendor/sqlite3/sqlite3.h" + #include "core/button.h" #include "core/icon_button.h" #include "core/toolbar.h" @@ -294,6 +296,11 @@ void views_toolbar(AppState& app) { app.show_open_modal = true; } ImGui::SameLine(); + if (button(TI_TABLE " Import dataset...", ButtonVariant::Secondary)) { + app.show_import_modal = true; + app.import_error.clear(); + } + ImGui::SameLine(); // Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo // confirman; main.cpp inserta en operations.db y dispara reload. @@ -1599,6 +1606,250 @@ void views_table(AppState& app) { ImGui::End(); } +// ---------------------------------------------------------------------------- +// Table node UI fase 2 (issue 0011) — ventana expandida + import +// ---------------------------------------------------------------------------- + +void views_table_windows_sync(AppState& app, const char* ops_db) { + if (!app.graph || !ops_db) return; + GraphData& g = *app.graph; + + // Construir set de Tables expandidas con su metadata fresca. + std::unordered_map live; + for (int i = 0; i < g.node_count; ++i) { + const GraphNode& n = g.nodes[i]; + if (n.type_id >= (uint16_t)g.type_count) continue; + const EntityType& t = g.types[n.type_id]; + if (!t.name || std::strcmp(t.name, "Table") != 0) continue; + // Resolver entity_id via SQL inverso por user_data hash es caro; + // hacemos una pasada SQL para todas las Table entities. + } + sqlite3* db = nullptr; + if (sqlite3_open_v2(ops_db, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return; + } + const char* sql = + "SELECT id FROM entities " + "WHERE type_ref = 'Table' AND json_extract(metadata,'$.expanded') = json('true')"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (!p) continue; + std::string id = (const char*)p; + TableMetadata meta; + if (tableview_get_metadata(ops_db, id.c_str(), &meta)) { + live.emplace(id, std::move(meta)); + } + } + sqlite3_finalize(st); + sqlite3_close(db); + + // Quitar las que ya no estan expanded. + for (auto it = app.table_windows.begin(); it != app.table_windows.end(); ) { + if (live.find(it->first) == live.end()) it = app.table_windows.erase(it); + else ++it; + } + // Anadir las nuevas o refrescar metadata. + for (auto& kv : live) { + auto& w = app.table_windows[kv.first]; + bool was_present = !w.meta.entity_id.empty(); + w.meta = std::move(kv.second); + w.open = true; + if (!was_present) { + w.offset = 0; + w.page.clear(); + w.total_rows = 0; + w.page_dirty = true; + } + } +} + +void views_table_window(AppState& app) { + if (app.table_windows.empty()) return; + GraphData* g = app.graph; + GraphViewportState* vp = app.viewport; + + for (auto& kv : app.table_windows) { + TableMetadata& m = kv.second.meta; + AppState::TableWindowState& w = kv.second; + + char title[160]; + std::snprintf(title, sizeof(title), TI_TABLE " %s##te_%s", + m.name.empty() ? "Table" : m.name.c_str(), + m.entity_id.c_str()); + ImGui::SetNextWindowSize(ImVec2(640, 460), ImGuiCond_FirstUseEver); + if (!ImGui::Begin(title, &w.open)) { ImGui::End(); continue; } + + // Header de info + ImGui::TextDisabled("%s · %s · %lld rows", + m.duckdb_path.c_str(), m.table_name.c_str(), + (long long)w.total_rows); + ImGui::Separator(); + + // Tabla + const int col_count = (int)m.columns.size() + 2; // id + columns... + promoted + ImGuiTableFlags tflags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("##te_rows", col_count, tflags, + ImVec2(0, -ImGui::GetFrameHeightWithSpacing()))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(), + ImGuiTableColumnFlags_WidthFixed, 100.0f); + for (const auto& c : m.columns) { + ImGui::TableSetupColumn(c.c_str(), ImGuiTableColumnFlags_WidthStretch); + } + ImGui::TableSetupColumn("promoted", + ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableHeadersRow(); + + // Decidir paginacion por scroll: pedimos siempre 200 filas a + // partir de offset; si el usuario llega cerca del final, + // avanzamos offset. + const int64_t page_size = 200; + for (int64_t i = 0; i < (int64_t)w.page.size(); ++i) { + const TablePageRow& row = w.page[i]; + ImGui::TableNextRow(); + ImGui::PushID((int)(w.offset + i)); + + ImGui::TableSetColumnIndex(0); + bool is_promoted = !row.promoted_entity_id.empty(); + ImGui::TextUnformatted(row.id.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { + if (is_promoted) { + app.want_focus_entity = true; + app.focus_entity_id = row.promoted_entity_id; + } else { + app.want_promote_row = true; + app.promote_table_id = m.entity_id; + app.promote_row_id = row.id; + } + } + if (ImGui::BeginPopupContextItem("##trowctx")) { + if (is_promoted) { + if (ImGui::MenuItem(TI_FOCUS " Focus in Inspector")) { + app.want_focus_entity = true; + app.focus_entity_id = row.promoted_entity_id; + } + if (ImGui::MenuItem(TI_X " Demote (delete entity)")) { + app.want_demote_entity = true; + app.demote_entity_id = row.promoted_entity_id; + } + } else { + if (ImGui::MenuItem(TI_PLUS " Promote to graph node")) { + app.want_promote_row = true; + app.promote_table_id = m.entity_id; + app.promote_row_id = row.id; + } + } + ImGui::EndPopup(); + } + + for (size_t c = 0; c < m.columns.size(); ++c) { + ImGui::TableSetColumnIndex(1 + (int)c); + if (c < row.values.size()) + ImGui::TextUnformatted(row.values[c].c_str()); + } + ImGui::TableSetColumnIndex(col_count - 1); + if (is_promoted) { + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(0.6f, 0.95f, 0.6f, 1.0f)); + ImGui::TextUnformatted("yes"); + ImGui::PopStyleColor(); + } else { + ImGui::TextDisabled("-"); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + // Footer: paginacion manual (offset). + bool has_prev = w.offset > 0; + bool has_next = w.offset + (int64_t)w.page.size() < w.total_rows; + if (!has_prev) ImGui::BeginDisabled(); + if (fn_ui::button(TI_ARROW_LEFT " Prev", fn_ui::ButtonVariant::Subtle)) { + w.offset = std::max(0, w.offset - 200); + w.page_dirty = true; + } + if (!has_prev) ImGui::EndDisabled(); + ImGui::SameLine(); + if (!has_next) ImGui::BeginDisabled(); + if (fn_ui::button("Next " TI_ARROW_RIGHT, fn_ui::ButtonVariant::Subtle)) { + w.offset = w.offset + 200; + w.page_dirty = true; + } + if (!has_next) ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::TextDisabled("rows %lld-%lld of %lld", + (long long)w.offset + (w.page.empty() ? 0 : 1), + (long long)(w.offset + (int64_t)w.page.size()), + (long long)w.total_rows); + ImGui::SameLine(); + if (fn_ui::button(TI_REFRESH " Reload", fn_ui::ButtonVariant::Subtle)) { + w.page_dirty = true; + } + + ImGui::End(); + + (void)g; (void)vp; + } + + // Cerrar la ventana = expanded=false. Lo procesa main.cpp leyendo + // table_windows y comparando `open`. +} + +bool views_import_dataset_modal(AppState& app) { + if (!app.show_import_modal) return false; + bool submitted = false; + if (fn_ui::modal_dialog_begin("Import dataset", &app.show_import_modal, + ImVec2(560, 0))) { + ImGui::TextWrapped( + "Crea una nueva tabla DuckDB importando un fichero CSV/Parquet/JSON. " + "Tras el import, se anade un nodo Table apuntando a la nueva tabla."); + ImGui::Spacing(); + fn_ui::text_input("File path", + app.import_path_buf, sizeof(app.import_path_buf), + "tables/people.csv"); + fn_ui::text_input("DuckDB path", + app.import_duckdb_buf, sizeof(app.import_duckdb_buf), + "tables/people.duckdb"); + fn_ui::text_input("Dest table", + app.import_table_buf, sizeof(app.import_table_buf), + "people"); + fn_ui::text_input("Row type", + app.import_row_type_buf,sizeof(app.import_row_type_buf), + "Person"); + if (!app.import_error.empty()) { + ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", + app.import_error.c_str()); + } + ImGui::Spacing(); + if (fn_ui::button("Import", fn_ui::ButtonVariant::Primary)) { + if (app.import_path_buf[0] && app.import_duckdb_buf[0] + && app.import_table_buf[0]) { + app.want_import = true; + submitted = true; + } else { + app.import_error = "File path, DuckDB path y dest table son obligatorios."; + } + } + ImGui::SameLine(); + if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) { + app.show_import_modal = false; + app.import_error.clear(); + } + } + fn_ui::modal_dialog_end(); + return submitted; +} + // ---------------------------------------------------------------------------- // Table node overlay (issue 0010) // ---------------------------------------------------------------------------- diff --git a/views.h b/views.h index ea8b06b..5ad581a 100644 --- a/views.h +++ b/views.h @@ -5,6 +5,7 @@ #include "types_registry.h" #include "entity_ops.h" +#include "tableview.h" #include #include @@ -145,6 +146,45 @@ struct AppState { // Refrescado tras load_input y tras mutaciones que afecten a Tables. std::unordered_map table_node_counts; + // ---- Table node UI fase 2 (issue 0011) -------------------------------- + // Estado runtime por ventana de Table expandida. Una entrada por + // entity_id de Table que el usuario haya expandido. La ventana se cierra + // cuando set_expanded(false) — ya sea desde context menu o cerrando la + // ImGui window (que pone el flag a false automaticamente). + struct TableWindowState { + TableMetadata meta; // refrescada cada vez que entity cambia + int64_t total_rows = 0; + int64_t offset = 0; + std::vector page; + bool page_dirty = true; + bool open = true; // bound a ImGui::Begin + }; + std::unordered_map table_windows; + + // Triggers consumidos por main.cpp tras click en filas. + bool want_promote_row = false; + std::string promote_table_id; // entity_id del Table de origen + std::string promote_row_id; // valor del id_column + + bool want_demote_entity = false; + std::string demote_entity_id; + + bool want_focus_entity = false; // tras promote+open inspector + std::string focus_entity_id; + + // Modal "Import dataset..." (issue 0011 Ingesta). + bool show_import_modal = false; + char import_path_buf[512] = {}; + char import_table_buf[64] = {}; + char import_duckdb_buf[256] = {}; // relativo a project root + char import_row_type_buf[64] = {}; + bool want_import = false; + std::string import_error; + + // Toggle expanded desde context menu del viewport. + bool want_toggle_expanded = false; + std::string toggle_expanded_id; + // ---- Table view (issue 0004) ------------------------------------------- // Vista tabular dockeable. Tabs por type_ref del grafo activo + opcional // "All". Click selecciona el nodo en el viewport (mismo flujo que el @@ -251,6 +291,25 @@ EntityRecord views_inspector_build_record(const AppState& app); // al cambiar de proyecto. void views_inspector_clear_draft(AppState& app); +// ---- Table node UI fase 2 (issue 0011) ---------------------------------- + +// Renderiza una ventana ImGui dockeable por cada Table en table_windows +// con `open=true`. Cabecera con nombres de columnas. Filas paginadas con +// ImGuiListClipper consumiendo el page cache; al cambiar el offset, marca +// dirty para que main.cpp refresque via tableview_page. Doble click en +// fila no promovida -> setea promote_table_id/promote_row_id; promovida +// -> focus_entity_id. Cerrar la ventana setea expanded=false en BD. +void views_table_window(AppState& app); + +// Modal "Import dataset..." — formulario para crear una tabla DuckDB +// desde CSV/Parquet/JSON y registrar el nodo Table correspondiente. +bool views_import_dataset_modal(AppState& app); + +// Sincroniza table_windows con la metadata.expanded de cada nodo Table. +// Llamar tras load + tras mutaciones que cambien expanded. Crea entradas +// para nuevos expanded y borra las que ya no aplican. +void views_table_windows_sync(AppState& app, const char* ops_db); + // ---- Table node overlay (issue 0010) ------------------------------------ // Dibuja un overlay rectangulo redondeado sobre cada nodo `Table` del grafo From 6c1be87a2d9758f993ea31ba1786d04a24a7a042 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:53:12 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(main):=20wire=200011=20=E2=80=94=20con?= =?UTF-8?q?text=20menu=20+=20triggers=20+=20sync=20de=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Context menu del viewport detecta type_ref='Table' y anade Expand/ Collapse table. Toggle escribe metadata.expanded en BD y resincroniza table_windows. - Triggers want_promote_row -> tableview_promote_row + reload + focus inspector con la entidad recien creada. - want_demote_entity -> tableview_demote_row + reload. - want_focus_entity: resuelve entity_id -> node_idx via FNV1a, centra camara, abre inspector. - want_import -> tableview_ingest_file + tableview_create + reload. - Loop por table_windows page_dirty -> tableview_count + (si columns vacios) descubre+persiste columnas + tableview_page. - Cierre via X de ventana detectado leyendo open=false; bajamos expanded en BD y borramos del mapa. - Sync de table_windows tras load_input y reload_after_mutation. - views_table_window + views_import_dataset_modal llamados en render(). --- main.cpp | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/main.cpp b/main.cpp index 6c264d5..caec24b 100644 --- a/main.cpp +++ b/main.cpp @@ -264,6 +264,9 @@ static bool load_input() { std::fprintf(stdout, "[graph_explorer] table counts refreshed: %zu tables, %lld total rows\n", g_app.table_node_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_table_windows_sync(g_app, g_input.uri); } // Cache de la vista tabla (issue 0004) — pull bulk + neighbors desde grafo. @@ -420,6 +423,28 @@ static void render_context_menu() { 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 expanded actual sin ir a BD: mira table_windows. + bool currently_expanded = + g_app.table_windows.find(sql_id) != g_app.table_windows.end(); + const char* lbl_exp = currently_expanded + ? TI_X " Collapse table" + : TI_TABLE " Expand table"; + if (ImGui::MenuItem(lbl_exp)) { + g_app.want_toggle_expanded = true; + g_app.toggle_expanded_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). @@ -614,6 +639,7 @@ static void render() { load_input(); } + // ---- Type Editor (issue 0007) ---- if (g_app.want_types_save) { g_app.want_types_save = false; @@ -705,6 +731,9 @@ static void render() { // Refresh Table node counts (issue 0010). ge::tableview_refresh_counts(g_input.uri, &g_app.table_node_counts); + // Sincroniza windows (issue 0011) por si una Table aparecio o desaparecio. + ge::views_table_windows_sync(g_app, g_input.uri); + // Refresh table cache (issue 0004). std::vector snap; if (ge::entity_list_rows(g_input.uri, &snap)) { @@ -802,6 +831,132 @@ static void render() { g_app.want_change_type = false; } + // ---- Table node UI fase 2 (issue 0011) ---- + if (g_app.want_toggle_expanded && !g_app.toggle_expanded_id.empty() + && !g_input_path.empty()) { + std::string id = g_app.toggle_expanded_id; + bool currently = g_app.table_windows.find(id) != g_app.table_windows.end(); + ge::tableview_set_expanded(g_input_path.c_str(), id.c_str(), !currently); + ge::views_table_windows_sync(g_app, g_input_path.c_str()); + g_app.want_toggle_expanded = false; + g_app.toggle_expanded_id.clear(); + } + // Cierre via X de la ventana -> bajar expanded en BD. + for (auto it = g_app.table_windows.begin(); it != g_app.table_windows.end(); ) { + if (!it->second.open && !g_input_path.empty()) { + ge::tableview_set_expanded(g_input_path.c_str(), + it->first.c_str(), false); + it = g_app.table_windows.erase(it); + } else ++it; + } + // Refrescar la pagina si alguna window esta dirty. + for (auto& kv : g_app.table_windows) { + auto& w = kv.second; + if (!w.page_dirty) continue; + const auto& m = w.meta; + ge::tableview_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 (m.columns.empty()) { + std::vector cols; + if (ge::tableview_list_columns(m.duckdb_path_abs.c_str(), + m.table_name.c_str(), &cols)) { + ge::tableview_set_columns(g_input_path.c_str(), + m.entity_id.c_str(), cols); + w.meta.columns = cols; + } + } + ge::tableview_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); + w.page_dirty = false; + } + if (g_app.want_promote_row && !g_app.promote_table_id.empty() + && !g_input_path.empty()) { + ge::TableMetadata m; + if (ge::tableview_get_metadata(g_input_path.c_str(), + g_app.promote_table_id.c_str(), &m)) { + char new_id[128] = {}; + if (ge::tableview_promote_row(g_input_path.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.table_windows.find(g_app.promote_table_id); + if (it != g_app.table_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::tableview_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.table_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::tableview_resolve_path( + g_input_path.c_str(), g_app.import_duckdb_buf); + std::string err; + if (!ge::tableview_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::tableview_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; @@ -1001,6 +1156,10 @@ static void render() { 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_table_window(g_app); + ge::views_import_dataset_modal(g_app); + g_first_render = false; } From cf279672fe8f2ac19c48f297dbad197b8b5efc45 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:53:31 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(issues):=20close=200011=20=E2=80=94=20?= =?UTF-8?q?frontmatter=20status=3Dcompleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- issues/completed/0011-tablenode-expanded-promote.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/issues/completed/0011-tablenode-expanded-promote.md b/issues/completed/0011-tablenode-expanded-promote.md index 9341540..fd7c9f9 100644 --- a/issues/completed/0011-tablenode-expanded-promote.md +++ b/issues/completed/0011-tablenode-expanded-promote.md @@ -1,9 +1,10 @@ --- id: 0011 title: Nodo tabla — UI expandida, promote/demote, ingesta CSV/Parquet -status: pending +status: completed priority: high created: 2026-05-01 +completed: 2026-05-01 depends_on: [0010] ---