From 84afa4ce70ce2e19559051dc528e9e1be5f9a678 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 1 May 2026 01:05:03 +0200 Subject: [PATCH] feat(table): vista tabla por tipo de entidad (issue 0004) - entity_ops: entity_list_rows (bulk pull id/name/type_ref/status/updated_at). - AppState::TableRow + cache + filtros (search substring + show_all toggle). - views_table: tabs por type_ref (alfabetico) o tabla unica con todos los tipos. ImGui::BeginTable con sort + clipper para >10k filas. Click en Selectable selecciona el nodo en el viewport (clear + add via graph_viewport_*). - views_table_refresh_indices: degree + node_idx por user_data hash. - main.cpp: panel "Table" en g_panels; cache build tras load_input y reload_after_mutation. --- entity_ops.cpp | 38 ++++ entity_ops.h | 15 ++ issues/{ => completed}/0004-table-view.md | 3 +- main.cpp | 44 +++++ views.cpp | 208 ++++++++++++++++++++++ views.h | 32 ++++ 6 files changed, 339 insertions(+), 1 deletion(-) rename issues/{ => completed}/0004-table-view.md (95%) diff --git a/entity_ops.cpp b/entity_ops.cpp index a60bd3d..5399d03 100644 --- a/entity_ops.cpp +++ b/entity_ops.cpp @@ -772,6 +772,44 @@ bool entity_list_by_tags(const char* db_path, return true; } +bool entity_list_rows(const char* db_path, + std::vector* out) +{ + if (!db_path || !out) return false; + out->clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = + "SELECT id, COALESCE(name,''), COALESCE(type_ref,''), " + " COALESCE(status,''), COALESCE(updated_at,'') " + "FROM entities ORDER BY type_ref, name"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + while (sqlite3_step(st) == SQLITE_ROW) { + EntityRowSnapshot r; + const unsigned char* a0 = sqlite3_column_text(st, 0); + const unsigned char* a1 = sqlite3_column_text(st, 1); + const unsigned char* a2 = sqlite3_column_text(st, 2); + const unsigned char* a3 = sqlite3_column_text(st, 3); + const unsigned char* a4 = sqlite3_column_text(st, 4); + r.id = a0 ? (const char*)a0 : ""; + r.name = a1 ? (const char*)a1 : ""; + r.type_ref = a2 ? (const char*)a2 : ""; + r.status = a3 ? (const char*)a3 : ""; + r.updated_at = a4 ? (const char*)a4 : ""; + out->push_back(std::move(r)); + } + sqlite3_finalize(st); + sqlite3_close(db); + return true; +} + // ---------------------------------------------------------------------------- // Index user_data -> sql id // ---------------------------------------------------------------------------- diff --git a/entity_ops.h b/entity_ops.h index da5d384..255848c 100644 --- a/entity_ops.h +++ b/entity_ops.h @@ -122,6 +122,21 @@ bool entity_list_by_tags(const char* db_path, const std::vector& tags, std::vector* out_ids); +// Snapshot ligero por entidad para la vista tabla (issue 0004). No incluye +// metadata ni notes — solo identidad + estado para tabular y ordenar. +struct EntityRowSnapshot { + std::string id; + std::string name; + std::string type_ref; + std::string status; + std::string updated_at; +}; + +// Carga todas las filas de `entities` ordenadas por type_ref, name. Tolera BD +// sin la columna `status` o `updated_at` — esos campos quedan vacios. +bool entity_list_rows(const char* db_path, + std::vector* out); + // Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada // carga del grafo (graph_sources usa FNV1a sobre id como user_data). struct EntityIndex { diff --git a/issues/0004-table-view.md b/issues/completed/0004-table-view.md similarity index 95% rename from issues/0004-table-view.md rename to issues/completed/0004-table-view.md index 2497bdf..cef4427 100644 --- a/issues/0004-table-view.md +++ b/issues/completed/0004-table-view.md @@ -1,9 +1,10 @@ --- id: 0004 title: Vista tabla — entidades agrupadas por tipo -status: pending +status: completed priority: medium created: 2026-04-30 +completed: 2026-05-01 --- ## Objetivo diff --git a/main.cpp b/main.cpp index 49edc21..dc0a249 100644 --- a/main.cpp +++ b/main.cpp @@ -253,6 +253,26 @@ static bool load_input() { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); + // 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); + 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. @@ -451,6 +471,7 @@ static fn_ui::PanelToggle g_panels[] = { {"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}, }; static void render() { @@ -668,6 +689,23 @@ static void render() { ge::views_reset_visibility(g_app); ge::views_apply_visibility(g_app); + // 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); + 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); @@ -939,6 +977,12 @@ static void render() { 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); + g_first_render = false; } diff --git a/views.cpp b/views.cpp index de41ec8..bc6f0a8 100644 --- a/views.cpp +++ b/views.cpp @@ -1391,6 +1391,214 @@ bool views_open_modal(AppState& app) { return opened; } +// ---------------------------------------------------------------------------- +// Table view (issue 0004) +// ---------------------------------------------------------------------------- + +void views_table_refresh_indices(AppState& app) { + if (!app.graph) return; + GraphData& g = *app.graph; + + // Degree map: user_data -> count. + std::unordered_map deg; + deg.reserve((size_t)g.node_count * 2); + for (int i = 0; i < g.edge_count; ++i) { + const GraphEdge& e = g.edges[i]; + if (e.source < (uint32_t)g.node_count) deg[g.nodes[e.source].user_data]++; + if (e.target < (uint32_t)g.node_count) deg[g.nodes[e.target].user_data]++; + } + + for (auto& r : app.table_rows) { + uint64_t h = fnv1a64_id(r.id.c_str()); + r.node_idx = g.find_node_by_user_data(h); + auto it = deg.find(h); + r.neighbors = (it == deg.end()) ? 0 : it->second; + } +} + +namespace { + +// Comparador estable para ImGuiTableSortSpecs. +struct TableSortCtx { + const ImGuiTableSortSpecs* specs; +}; +TableSortCtx g_table_sort_ctx; + +bool table_row_lt(const AppState::TableRow& a, const AppState::TableRow& b) { + const ImGuiTableSortSpecs* specs = g_table_sort_ctx.specs; + if (!specs) return a.name < b.name; + for (int n = 0; n < specs->SpecsCount; ++n) { + const ImGuiTableColumnSortSpecs& s = specs->Specs[n]; + int delta = 0; + switch (s.ColumnUserID) { + case 0: delta = a.id.compare(b.id); break; + case 1: delta = a.name.compare(b.name); break; + case 2: delta = a.type_ref.compare(b.type_ref); break; + case 3: delta = a.status.compare(b.status); break; + case 4: delta = a.updated_at.compare(b.updated_at); break; + case 5: delta = (a.neighbors < b.neighbors) ? -1 + : (a.neighbors > b.neighbors) ? 1 : 0; break; + default: break; + } + if (delta != 0) { + return (s.SortDirection == ImGuiSortDirection_Ascending) ? (delta < 0) : (delta > 0); + } + } + return false; +} + +bool ci_contains(const std::string& hay, const char* needle) { + if (!needle || !*needle) return true; + auto lower = [](char c){ return (char)std::tolower((unsigned char)c); }; + std::string h; h.reserve(hay.size()); + for (char c : hay) h.push_back(lower(c)); + std::string n; + for (const char* p = needle; *p; ++p) n.push_back(lower(*p)); + return h.find(n) != std::string::npos; +} + +void render_one_table(AppState& app, std::vector& visible_indices) { + ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY | + ImGuiTableFlags_SizingStretchProp; + if (!ImGui::BeginTable("##tablev", 6, flags)) return; + + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_DefaultSort, 0, 0); + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_None, 0, 1); + ImGui::TableSetupColumn("type", ImGuiTableColumnFlags_None, 0, 2); + ImGui::TableSetupColumn("status", ImGuiTableColumnFlags_None, 0, 3); + ImGui::TableSetupColumn("updated_at", ImGuiTableColumnFlags_None, 0, 4); + ImGui::TableSetupColumn("neighbors", ImGuiTableColumnFlags_WidthFixed, + 80.0f, 5); + ImGui::TableHeadersRow(); + + ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs(); + if (specs && specs->SpecsDirty) { + g_table_sort_ctx.specs = specs; + std::sort(visible_indices.begin(), visible_indices.end(), + [&app](int a, int b) { + return table_row_lt(app.table_rows[a], app.table_rows[b]); + }); + specs->SpecsDirty = false; + } + + ImGuiListClipper clipper; + clipper.Begin((int)visible_indices.size()); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + int ri = visible_indices[row]; + const auto& r = app.table_rows[ri]; + ImGui::TableNextRow(); + ImGui::PushID(ri); + + ImGui::TableSetColumnIndex(0); + char sel_lbl[256]; + std::snprintf(sel_lbl, sizeof(sel_lbl), "%s##sel", r.id.c_str()); + bool is_sel = (app.viewport && r.node_idx >= 0 + && graph_viewport_is_selected(*app.viewport, r.node_idx)); + if (ImGui::Selectable(sel_lbl, is_sel, + ImGuiSelectableFlags_SpanAllColumns)) { + if (r.node_idx >= 0 && app.graph && app.viewport) { + graph_viewport_clear_selection(*app.graph, *app.viewport); + graph_viewport_add_to_selection(*app.graph, *app.viewport, + r.node_idx); + app.filter_focus_target = r.node_idx; + } + } + + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(r.name.c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(r.type_ref.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(r.status.c_str()); + ImGui::TableSetColumnIndex(4); + ImGui::TextUnformatted(r.updated_at.c_str()); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%d", r.neighbors); + + ImGui::PopID(); + } + } + ImGui::EndTable(); +} + +} // namespace + +void views_table(AppState& app) { + if (!app.panel_table) return; + if (!ImGui::Begin("Table", &app.panel_table)) { + ImGui::End(); + return; + } + + // Toolbar superior: search + show all. + ImGui::SetNextItemWidth(220); + ImGui::InputTextWithHint("##tsearch", TI_SEARCH " filter name/id...", + app.table_search_buf, sizeof(app.table_search_buf)); + ImGui::SameLine(); + ImGui::Checkbox("Show all types", &app.table_show_all); + ImGui::SameLine(); + ImGui::TextDisabled("%zu rows", app.table_rows.size()); + + if (app.table_rows.empty()) { + ImGui::TextDisabled("(no entities loaded)"); + ImGui::End(); + return; + } + + // Indices por tipo. + std::vector types_present; + types_present.reserve(8); + { + std::unordered_set seen; + for (const auto& r : app.table_rows) { + if (seen.insert(r.type_ref).second) types_present.push_back(r.type_ref); + } + std::sort(types_present.begin(), types_present.end()); + } + + auto build_visible = [&](const char* type_filter) { + std::vector v; + v.reserve(app.table_rows.size()); + for (size_t i = 0; i < app.table_rows.size(); ++i) { + const auto& r = app.table_rows[i]; + if (type_filter && r.type_ref != type_filter) continue; + if (app.table_search_buf[0] + && !ci_contains(r.name, app.table_search_buf) + && !ci_contains(r.id, app.table_search_buf)) continue; + v.push_back((int)i); + } + return v; + }; + + if (app.table_show_all) { + auto visible = build_visible(nullptr); + ImGui::TextDisabled("All types — %zu visible", visible.size()); + render_one_table(app, visible); + } else if (ImGui::BeginTabBar("##ttabs")) { + for (size_t i = 0; i < types_present.size(); ++i) { + const std::string& t = types_present[i]; + char lbl[96]; + std::snprintf(lbl, sizeof(lbl), "%s##tt%zu", + t.empty() ? "(none)" : t.c_str(), i); + if (ImGui::BeginTabItem(lbl)) { + app.table_active_tab = (int)i; + auto visible = build_visible(t.c_str()); + ImGui::TextDisabled("%zu rows visible", visible.size()); + render_one_table(app, visible); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + // ---------------------------------------------------------------------------- // Type Editor (issue 0007) // ---------------------------------------------------------------------------- diff --git a/views.h b/views.h index 825f575..0b50d69 100644 --- a/views.h +++ b/views.h @@ -137,6 +137,26 @@ struct AppState { std::vector insp_tag_suggestions; std::vector insp_type_options; + // ---- 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 + // Selectable del Inspector). + struct TableRow { + std::string id; + std::string name; + std::string type_ref; + std::string status; + std::string updated_at; + int neighbors = 0; + int node_idx = -1; + }; + bool panel_table = false; + std::vector table_rows; // snapshot, refrescado tras load/reload + bool table_cache_dirty = true; + char table_search_buf[96] = {}; + bool table_show_all = false; + int table_active_tab = 0; + // ---- Type Editor (issue 0007) ------------------------------------------ // Draft del editor de tipos. Se inicializa con una copia de parsed_types // tras cargar el grafo. Save reescribe `types.yaml` y dispara @@ -223,6 +243,18 @@ EntityRecord views_inspector_build_record(const AppState& app); // al cambiar de proyecto. void views_inspector_clear_draft(AppState& app); +// ---- Table view (issue 0004) -------------------------------------------- + +// Renderiza el panel "Table". Lee de app.table_rows; el caller ya ha hecho el +// build/refresh tras cargar el grafo. Click en fila selecciona el nodo en el +// viewport (mismo flujo que el Selectable del Inspector). Filtro por +// substring sobre name/id en la cabecera. +void views_table(AppState& app); + +// Recompute neighbors[] y node_idx[] de las filas existentes a partir del +// grafo cargado. Llamar tras cargar el grafo o tras una mutacion. +void views_table_refresh_indices(AppState& app); + // ---- Type Editor (issue 0007) ------------------------------------------- // Renderiza el panel "Types" — tabs Entities/Relations, lista a la izquierda