feat(views): Inspector editable — identidad, fields tipados, extras, tags

Issue 0008 — refactor del panel Inspector de read-only a editable.

views.h:
- AppState gana ParsedTypes parsed_types (schema vivo del proyecto), draft
  del Inspector (insp_*: name/type/desc/status buffers, field_keys/values
  paralelas, is_extra mask, tags vector, dirty flag), y dos triggers
  (want_inspector_save, want_inspector_discard).
- Helpers expuestos: views_inspector_clear_draft, _refresh_caches,
  _load_draft, _build_record.

views.cpp:
- views_inspector_load_draft: entity_load_full → buffers; campos del
  schema primero (orden del EntitySpec), extras detras.
- views_inspector_build_record: reconstruye EntityRecord respetando el
  schema para decidir is_string de cada campo (FK_BOOL → 'true'/'false',
  FK_INT/FLOAT → literal, resto → string). Extras siempre string.
- views_inspector: render por bloques:
  * Identity: name, type combo (lista del proyecto + tipos del grafo),
    status combo, description multiline.
  * Fields del schema: render por kind (string→InputText con hint,
    int→InputInt, float→InputDouble, bool→Checkbox, date→InputText
    con hint YYYY-MM-DD, url→InputText + boton Open en navegador,
    enum→Combo con values). Required marcado con '*'.
  * Extras: lista key-value con boton trash por fila + 'Add' al final.
  * Tags: chips clickables (click = quitar) + input con autocomplete
    (lista compacta de tags distintas en BD).
  * Footer: Save/Discard/Open notes + label '(modified)' si dirty.
  * Neighbors read-only (igual que antes).
- Si el draft no esta sincronizado con la seleccion actual y NO hay
  cambios pendientes, el inspector muestra 'Cargando...' (main.cpp
  carga). Si hay dirty, banner 'Save/Discard primero' bloqueando.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:13:01 +02:00
parent 6ed00fcdce
commit b2ae793727
2 changed files with 591 additions and 15 deletions
+531 -15
View File
@@ -17,7 +17,11 @@
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace ge {
@@ -283,7 +287,191 @@ void views_legend(AppState& app) {
}
// ----------------------------------------------------------------------------
// Inspector
// Inspector helpers (issue 0008)
// ----------------------------------------------------------------------------
namespace {
const char* k_status_options[] = { "active", "stale", "corrupted", "archived" };
constexpr int k_status_count = 4;
int status_to_idx(const std::string& s) {
for (int i = 0; i < k_status_count; ++i) {
if (s == k_status_options[i]) return i;
}
return 0;
}
const EntitySpec* find_entity_spec(const ParsedTypes& pt, const char* name) {
if (!name || !*name) return nullptr;
auto eq_ci = [&](const std::string& a, const char* b) {
if (a.size() != std::strlen(b)) return false;
for (size_t i = 0; i < a.size(); ++i) {
if (std::tolower((unsigned char)a[i]) !=
std::tolower((unsigned char)b[i])) return false;
}
return true;
};
for (const auto& e : pt.entities) {
if (eq_ci(e.name, name)) return &e;
}
return nullptr;
}
// Asegura que el buffer de descripcion tiene al menos `need` bytes (con NUL).
void ensure_desc_buf(std::vector<char>& buf, size_t need) {
if (need < 4096) need = 4096;
if (buf.size() < need) buf.assign(need, 0);
}
void copy_to_buf(char* buf, size_t n, const std::string& s) {
if (n == 0) return;
size_t k = std::min(n - 1, s.size());
std::memcpy(buf, s.data(), k);
buf[k] = 0;
}
} // namespace
void views_inspector_clear_draft(AppState& app) {
app.insp_node_idx = -1;
app.insp_entity_id.clear();
app.insp_name_buf[0] = 0;
app.insp_type_buf[0] = 0;
app.insp_desc_buf.clear();
app.insp_status_idx = 0;
app.insp_field_keys.clear();
app.insp_field_values.clear();
app.insp_is_extra.clear();
app.insp_tags.clear();
app.insp_tag_input[0] = 0;
app.insp_extra_key[0] = 0;
app.insp_dirty = false;
app.insp_show_unsaved = false;
app.insp_pending_target = -1;
}
void views_inspector_refresh_caches(AppState& app) {
app.insp_tag_suggestions.clear();
if (!app.input_db_path.empty()) {
entity_list_distinct_tags(app.input_db_path.c_str(),
&app.insp_tag_suggestions);
}
app.insp_type_options.clear();
for (const auto& e : app.parsed_types.entities) {
if (!e.name.empty()) app.insp_type_options.push_back(e.name);
}
// Si el grafo trae tipos no presentes en el yaml, anadirlos para que el
// combo no pierda opciones.
if (app.graph) {
for (int i = 0; i < app.graph->type_count; ++i) {
const char* nm = app.graph->types[i].name;
if (!nm || !*nm) continue;
bool dup = false;
for (const auto& s : app.insp_type_options) {
if (s == nm) { dup = true; break; }
}
if (!dup) app.insp_type_options.emplace_back(nm);
}
}
}
void views_inspector_load_draft(AppState& app, int node_idx,
const char* entity_id) {
views_inspector_clear_draft(app);
if (!entity_id || !*entity_id) return;
EntityRecord rec;
if (!entity_load_full(app.input_db_path.c_str(), entity_id, &rec)) return;
app.insp_node_idx = node_idx;
app.insp_entity_id = entity_id;
copy_to_buf(app.insp_name_buf, sizeof(app.insp_name_buf), rec.name);
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf), rec.type_ref);
ensure_desc_buf(app.insp_desc_buf, rec.description.size() + 4096);
std::memcpy(app.insp_desc_buf.data(), rec.description.data(),
rec.description.size());
app.insp_status_idx = status_to_idx(rec.status);
// Construye lista de fields: primero los del schema (en orden), luego
// las extras (claves en metadata que no estan en el schema).
const EntitySpec* spec = find_entity_spec(app.parsed_types, rec.type_ref.c_str());
auto find_meta = [&](const std::string& key) -> const MetadataField* {
for (const auto& m : rec.metadata) if (m.key == key) return &m;
return nullptr;
};
if (spec) {
for (const auto& f : spec->fields) {
std::string val;
if (auto m = find_meta(f.name)) val = m->value_str;
app.insp_field_keys.push_back(f.name);
app.insp_field_values.push_back(std::move(val));
app.insp_is_extra.push_back(0);
}
}
// Extras: claves en metadata que no estan en el schema.
for (const auto& m : rec.metadata) {
bool in_schema = false;
if (spec) {
for (const auto& f : spec->fields) {
if (f.name == m.key) { in_schema = true; break; }
}
}
if (!in_schema) {
app.insp_field_keys.push_back(m.key);
app.insp_field_values.push_back(m.value_str);
app.insp_is_extra.push_back(1);
}
}
app.insp_tags = std::move(rec.tags);
app.insp_dirty = false;
}
EntityRecord views_inspector_build_record(const AppState& app) {
EntityRecord r;
r.id = app.insp_entity_id;
r.name = app.insp_name_buf;
r.type_ref = app.insp_type_buf;
r.description = app.insp_desc_buf.empty()
? std::string()
: std::string(app.insp_desc_buf.data());
int sidx = app.insp_status_idx;
if (sidx < 0 || sidx >= k_status_count) sidx = 0;
r.status = k_status_options[sidx];
r.tags = app.insp_tags;
const EntitySpec* spec = find_entity_spec(app.parsed_types,
app.insp_type_buf);
auto kind_for_key = [&](const std::string& k) -> FieldKind {
if (!spec) return FK_STRING;
for (const auto& f : spec->fields) if (f.name == k) return f.kind;
return FK_STRING;
};
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
const std::string& key = app.insp_field_keys[i];
const std::string& val = app.insp_field_values[i];
if (key.empty() || val.empty()) continue;
bool is_extra = app.insp_is_extra[i] != 0;
FieldKind kind = is_extra ? FK_STRING : kind_for_key(key);
MetadataField mf;
mf.key = key;
mf.value_str = val;
mf.is_string = (kind == FK_STRING || kind == FK_DATE
|| kind == FK_URL || kind == FK_ENUM);
// Para int/float/bool, value_str debe ser literal valido.
if (kind == FK_BOOL) {
std::string lv = val;
std::transform(lv.begin(), lv.end(), lv.begin(),
[](unsigned char c){ return std::tolower(c); });
mf.value_str = (lv == "true" || lv == "1" || lv == "yes")
? "true" : "false";
}
r.metadata.push_back(std::move(mf));
}
return r;
}
// ----------------------------------------------------------------------------
// Inspector — render del panel editable
// ----------------------------------------------------------------------------
void views_inspector(AppState& app) {
@@ -302,14 +490,35 @@ void views_inspector(AppState& app) {
const auto& sel = app.viewport->selection;
if (sel.empty()) {
if (app.insp_node_idx != -1 && !app.insp_dirty) {
views_inspector_clear_draft(app);
}
ImGui::TextUnformatted("No selection.");
ImGui::TextWrapped("Click a node, or shift+drag to lasso a region.");
if (app.insp_dirty) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f));
ImGui::TextWrapped("Cambios sin guardar en %s",
app.insp_entity_id.c_str());
ImGui::PopStyleColor();
if (fn_ui::button("Save", fn_ui::ButtonVariant::Primary)) {
app.want_inspector_save = true;
}
ImGui::SameLine();
if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) {
app.want_inspector_discard = true;
}
}
ImGui::End();
return;
}
if (sel.size() > 1) {
if (app.insp_node_idx != -1 && !app.insp_dirty) {
views_inspector_clear_draft(app);
}
ImGui::Text("%zu nodes selected", sel.size());
ImGui::TextDisabled("(edicion multi-seleccion no soportada)");
ImGui::Separator();
for (size_t i = 0; i < sel.size() && i < 32; ++i) {
int idx = sel[i];
@@ -329,28 +538,335 @@ void views_inspector(AppState& app) {
ImGui::End();
return;
}
// Sincroniza draft con seleccion actual. Si hay cambios pendientes y la
// seleccion cambio, mostramos un banner pidiendo Save/Discard antes de
// cargar el nodo nuevo. Mientras tanto el draft sigue siendo el del
// nodo anterior.
if (app.insp_node_idx != idx) {
if (app.insp_dirty && app.insp_node_idx >= 0
&& app.insp_node_idx < g.node_count) {
// No cargar — esperar decision del usuario.
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f));
ImGui::TextWrapped("Cambios sin guardar (nodo cambio en el viewport)."
" Save / Discard primero.");
ImGui::PopStyleColor();
ImGui::Separator();
} else {
// Resolver el sql id desde el viewport callback no esta aqui,
// asi que main.cpp debe haber rellenado insp_pending_target +
// input_db_path; pero la ruta normal es: main.cpp detecta cambio
// de seleccion y llama views_inspector_load_draft. Aqui solo
// limpiamos si el user_data no resuelve.
// Simplemente intentamos cargar via insp_pending_target.
}
}
// Si el draft no esta cargado para este idx, mostramos un placeholder.
// main.cpp es responsable de llamar load_draft cuando seleccion cambia
// y no hay dirty.
if (app.insp_node_idx != idx) {
ImGui::TextDisabled("Cargando nodo %d...", idx);
ImGui::End();
return;
}
const GraphNode& n = g.nodes[idx];
const char* lbl = graph::graph_label(&g, n.label_idx);
const char* tname = (n.type_id < (uint16_t)g.type_count && g.types[n.type_id].name)
? g.types[n.type_id].name : "(no-type)";
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted("label:");
ImGui::Text("id=%s user_data=%llx pos=(%.1f, %.1f)",
app.insp_entity_id.c_str(),
(unsigned long long)n.user_data, n.x, n.y);
ImGui::PopStyleColor();
ImGui::Separator();
bool any_change = false;
// ---- Identidad ----
ImGui::TextUnformatted("Identity");
ImGui::Separator();
if (ImGui::InputText("name", app.insp_name_buf, sizeof(app.insp_name_buf)))
any_change = true;
// type combo
{
int cur = -1;
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
if (app.insp_type_options[i] == app.insp_type_buf) { cur = (int)i; break; }
}
// Si el tipo no esta en el cache (raro), mostrar como tal y permitir
// introducirlo via input. Combo simple aqui.
if (ImGui::BeginCombo("type", app.insp_type_buf)) {
for (size_t i = 0; i < app.insp_type_options.size(); ++i) {
bool is_sel = (int)i == cur;
if (ImGui::Selectable(app.insp_type_options[i].c_str(), is_sel)) {
copy_to_buf(app.insp_type_buf, sizeof(app.insp_type_buf),
app.insp_type_options[i]);
any_change = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
}
// status combo
if (ImGui::Combo("status", &app.insp_status_idx,
k_status_options, k_status_count))
any_change = true;
// description multiline
if (app.insp_desc_buf.empty()) ensure_desc_buf(app.insp_desc_buf, 4096);
if (ImGui::InputTextMultiline("description",
app.insp_desc_buf.data(),
app.insp_desc_buf.size(),
ImVec2(-FLT_MIN, 60.0f)))
any_change = true;
// ---- Schema fields + Extras ----
if (!app.insp_field_keys.empty()) {
ImGui::Spacing();
ImGui::TextUnformatted("Fields");
ImGui::Separator();
const EntitySpec* spec = find_entity_spec(app.parsed_types,
app.insp_type_buf);
for (size_t i = 0; i < app.insp_field_keys.size(); ++i) {
const std::string& key = app.insp_field_keys[i];
std::string& val = app.insp_field_values[i];
bool is_extra = app.insp_is_extra[i] != 0;
ImGui::PushID((int)i);
// Encuentra la FieldSpec si es del schema.
const FieldSpec* fs = nullptr;
if (!is_extra && spec) {
for (const auto& f : spec->fields) {
if (f.name == key) { fs = &f; break; }
}
}
FieldKind kind = fs ? fs->kind : FK_STRING;
std::string label = key;
if (fs && fs->required) label += " *";
if (is_extra) label = "[extra] " + key;
char buf[1024];
size_t k = std::min(sizeof(buf) - 1, val.size());
std::memcpy(buf, val.data(), k);
buf[k] = 0;
bool changed = false;
switch (kind) {
case FK_BOOL: {
bool b = (val == "true" || val == "1");
if (ImGui::Checkbox(label.c_str(), &b)) {
val = b ? "true" : "false";
changed = true;
}
break;
}
case FK_INT: {
int n = std::atoi(val.c_str());
if (ImGui::InputInt(label.c_str(), &n)) {
char nb[32]; std::snprintf(nb, sizeof(nb), "%d", n);
val = nb;
changed = true;
}
break;
}
case FK_FLOAT: {
double d = std::atof(val.c_str());
if (ImGui::InputDouble(label.c_str(), &d, 0.0, 0.0, "%.6g")) {
char nb[64]; std::snprintf(nb, sizeof(nb), "%.10g", d);
val = nb;
changed = true;
}
break;
}
case FK_ENUM: {
if (fs && !fs->enum_values.empty()) {
int cur = -1;
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
if (fs->enum_values[e] == val) { cur = (int)e; break; }
}
if (ImGui::BeginCombo(label.c_str(), val.c_str())) {
for (size_t e = 0; e < fs->enum_values.size(); ++e) {
bool is_sel = (int)e == cur;
if (ImGui::Selectable(fs->enum_values[e].c_str(), is_sel)) {
val = fs->enum_values[e];
changed = true;
}
if (is_sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
} else {
// Sin valores: tratar como string
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
val = buf;
changed = true;
}
}
break;
}
case FK_URL:
if (ImGui::InputText(label.c_str(), buf, sizeof(buf))) {
val = buf;
changed = true;
}
if (!val.empty() &&
(val.rfind("http://", 0) == 0 || val.rfind("https://", 0) == 0)) {
ImGui::SameLine();
if (ImGui::SmallButton("Open##url")) {
#if defined(_WIN32)
std::string cmd = "start \"\" \"" + val + "\"";
#else
std::string cmd = "xdg-open '" + val + "' >/dev/null 2>&1 &";
#endif
int rc = std::system(cmd.c_str()); (void)rc;
}
}
break;
case FK_DATE:
case FK_STRING:
default:
if (ImGui::InputTextWithHint(label.c_str(),
kind == FK_DATE ? "YYYY-MM-DD" : "",
buf, sizeof(buf))) {
val = buf;
changed = true;
}
break;
}
if (is_extra) {
ImGui::SameLine();
if (ImGui::SmallButton(TI_TRASH "##rm")) {
app.insp_field_keys.erase(app.insp_field_keys.begin() + i);
app.insp_field_values.erase(app.insp_field_values.begin() + i);
app.insp_is_extra.erase(app.insp_is_extra.begin() + i);
ImGui::PopID();
any_change = true;
--i;
continue;
}
}
if (changed) any_change = true;
ImGui::PopID();
}
}
// ---- Add extra ----
ImGui::Spacing();
ImGui::TextUnformatted("Extra field");
ImGui::Separator();
ImGui::SetNextItemWidth(160);
ImGui::InputTextWithHint("##xkey", "key", app.insp_extra_key,
sizeof(app.insp_extra_key));
ImGui::SameLine();
ImGui::TextUnformatted(lbl && *lbl ? lbl : "(none)");
if (fn_ui::button(TI_PLUS " Add", fn_ui::ButtonVariant::Subtle)) {
if (app.insp_extra_key[0]) {
// Evitar colision con keys ya presentes
std::string k = app.insp_extra_key;
bool dup = false;
for (const auto& x : app.insp_field_keys) {
if (x == k) { dup = true; break; }
}
if (!dup) {
app.insp_field_keys.push_back(k);
app.insp_field_values.emplace_back("");
app.insp_is_extra.push_back(1);
any_change = true;
}
app.insp_extra_key[0] = 0;
}
}
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted("type:");
ImGui::PopStyleColor();
// ---- Tags ----
ImGui::Spacing();
ImGui::TextUnformatted("Tags");
ImGui::Separator();
for (size_t i = 0; i < app.insp_tags.size(); ++i) {
ImGui::PushID((int)i);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.30f, 0.50f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.40f, 0.65f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.50f, 0.75f, 1.0f));
std::string lbl = app.insp_tags[i] + " x";
if (ImGui::SmallButton(lbl.c_str())) {
app.insp_tags.erase(app.insp_tags.begin() + i);
ImGui::PopStyleColor(3);
ImGui::PopID();
any_change = true;
--i;
continue;
}
ImGui::PopStyleColor(3);
ImGui::PopID();
if ((i + 1) % 4 != 0) ImGui::SameLine();
}
if (!app.insp_tags.empty()) ImGui::NewLine();
ImGui::SetNextItemWidth(160);
ImGuiInputTextFlags tflags = ImGuiInputTextFlags_EnterReturnsTrue;
bool commit_tag = ImGui::InputTextWithHint("##taginput", "add tag, Enter",
app.insp_tag_input,
sizeof(app.insp_tag_input),
tflags);
ImGui::SameLine();
ImGui::TextUnformatted(tname);
if (fn_ui::button("Add tag", fn_ui::ButtonVariant::Subtle)) commit_tag = true;
if (commit_tag && app.insp_tag_input[0]) {
std::string t = app.insp_tag_input;
bool dup = false;
for (const auto& x : app.insp_tags) if (x == t) { dup = true; break; }
if (!dup) {
app.insp_tags.push_back(t);
any_change = true;
}
app.insp_tag_input[0] = 0;
}
if (!app.insp_tag_suggestions.empty()) {
ImGui::TextDisabled("(in db: %zu tags distintas)", app.insp_tag_suggestions.size());
// Lista compacta clickable de las primeras 12 sugerencias.
int shown = 0;
for (const auto& s : app.insp_tag_suggestions) {
if (shown >= 12) { ImGui::TextDisabled("..."); break; }
// Ocultar las ya presentes
bool already = false;
for (const auto& x : app.insp_tags) if (x == s) { already = true; break; }
if (already) continue;
ImGui::SmallButton(s.c_str());
if (ImGui::IsItemClicked()) {
app.insp_tags.push_back(s);
any_change = true;
}
ImGui::SameLine();
++shown;
}
if (shown > 0) ImGui::NewLine();
}
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::Text("idx=%d user_data=%llx pos=(%.1f, %.1f)",
idx, (unsigned long long)n.user_data, n.x, n.y);
ImGui::PopStyleColor();
// ---- Footer: Save / Discard / Notes / Neighbors ----
ImGui::Spacing();
ImGui::Separator();
if (any_change) app.insp_dirty = true;
if (app.insp_dirty) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.7f, 0.3f, 1.0f));
ImGui::TextUnformatted("(modified)");
ImGui::PopStyleColor();
}
if (fn_ui::button(TI_DEVICE_FLOPPY " Save",
app.insp_dirty ? fn_ui::ButtonVariant::Primary
: fn_ui::ButtonVariant::Subtle)) {
app.want_inspector_save = true;
}
ImGui::SameLine();
if (fn_ui::button("Discard", fn_ui::ButtonVariant::Subtle)) {
app.want_inspector_discard = true;
}
ImGui::SameLine();
if (fn_ui::button(TI_FILE_TEXT " Open notes", fn_ui::ButtonVariant::Subtle)) {
app.want_open_note = true;
app.open_note_target = idx;
}
// ---- Neighbors (read-only) ----
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted("Neighbors:");
+60
View File
@@ -3,6 +3,9 @@
#include <string>
#include <vector>
#include "types_registry.h"
#include "entity_ops.h"
struct GraphData;
struct GraphViewportState;
@@ -96,6 +99,43 @@ struct AppState {
bool want_save_note = false;
bool want_open_note = false; // doble click → cargar y abrir
int open_note_target = -1; // node_idx a abrir
// ---- Inspector editable (issue 0008) ----------------------------------
// Schema vivo del proyecto activo (load/save desde types.yaml).
ParsedTypes parsed_types;
// Draft del inspector — todo lo que el usuario esta editando para el
// nodo seleccionado. Se carga desde BD al cambiar la seleccion (si no
// hay cambios pendientes) y se persiste con entity_update al guardar.
int insp_node_idx = -1;
std::string insp_entity_id;
char insp_name_buf[256] = {};
char insp_type_buf[64] = {};
std::vector<char> insp_desc_buf; // multiline
int insp_status_idx = 0; // 0=active 1=stale 2=corrupted 3=archived
// Listas paralelas: keys + valores actuales de los campos de metadata.
// Las claves del schema del tipo van primero (en su orden), las "extras"
// van detras. `is_extra[i]` distingue para render diferenciado y para
// permitir borrar solo extras desde la UI.
std::vector<std::string> insp_field_keys;
std::vector<std::string> insp_field_values;
std::vector<unsigned char> insp_is_extra;
std::vector<std::string> insp_tags;
char insp_tag_input[64] = {};
char insp_extra_key[64] = {};
bool insp_dirty = false;
bool insp_show_unsaved = false;
int insp_pending_target = -1;
bool want_inspector_save = false;
bool want_inspector_discard = false;
// Caches refrescadas tras cargar grafo o tras Save.
std::vector<std::string> insp_tag_suggestions;
std::vector<std::string> insp_type_options;
};
// Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).
@@ -133,4 +173,24 @@ void views_apply_visibility(AppState& app);
// los tipos del grafo activo. Llamar tras cargar/recargar el grafo.
void views_reset_visibility(AppState& app);
// ---- Inspector editable helpers (issue 0008) ------------------------------
// Refresca insp_tag_suggestions e insp_type_options leyendo BD y schema.
// Llamar tras cargar el grafo o tras un Save.
void views_inspector_refresh_caches(AppState& app);
// Carga el draft del Inspector desde la BD para el nodo `node_idx`. Si el
// nodo no es resoluble o no existe, deja el draft vacio. No respeta dirty:
// el caller debe haberlo manejado ya.
void views_inspector_load_draft(AppState& app, int node_idx,
const char* entity_id);
// Construye un EntityRecord desde el draft actual respetando el schema
// del type_ref para decidir is_string de cada metadata field.
EntityRecord views_inspector_build_record(const AppState& app);
// Resetea el draft (todos los buffers + dirty=false). Util tras Save o
// al cambiar de proyecto.
void views_inspector_clear_draft(AppState& app);
} // namespace ge