merge: issue/0008-inspector-editable — Inspector editable
Editar name/type/description/status, fields tipados (string/int/float/ bool/date/url/enum) renderizados desde el schema del tipo, extras key-value libres, tags como chips con autocomplete por la BD. Save persiste con un solo UPDATE y dispara reload del grafo. Cierra issue 0008.
This commit is contained in:
+323
@@ -329,6 +329,329 @@ bool entity_set_notes(const char* db_path, const char* id, const char* notes) {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// JSON helpers — parser + writer minimal para objetos planos y arrays de
|
||||||
|
// strings. NO es un parser JSON general (no soporta objetos anidados, ni
|
||||||
|
// arrays anidados, ni escapes Unicode \uXXXX). Suficiente para tags JSON
|
||||||
|
// y metadata flat del Inspector.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void skip_ws(const char*& p, const char* end) {
|
||||||
|
while (p < end) {
|
||||||
|
char c = *p;
|
||||||
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { ++p; continue; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse un string JSON: comillas + contenido con escapes basicos. Devuelve
|
||||||
|
// el contenido decodificado en `out`. Avanza `p`. Retorna false si malformado.
|
||||||
|
bool parse_json_string(const char*& p, const char* end, std::string* out) {
|
||||||
|
if (p >= end || *p != '"') return false;
|
||||||
|
++p;
|
||||||
|
out->clear();
|
||||||
|
while (p < end) {
|
||||||
|
char c = *p++;
|
||||||
|
if (c == '"') return true;
|
||||||
|
if (c == '\\' && p < end) {
|
||||||
|
char e = *p++;
|
||||||
|
switch (e) {
|
||||||
|
case 'n': out->push_back('\n'); break;
|
||||||
|
case 't': out->push_back('\t'); break;
|
||||||
|
case 'r': out->push_back('\r'); break;
|
||||||
|
case '"': out->push_back('"'); break;
|
||||||
|
case '\\': out->push_back('\\'); break;
|
||||||
|
case '/': out->push_back('/'); break;
|
||||||
|
case 'b': out->push_back('\b'); break;
|
||||||
|
case 'f': out->push_back('\f'); break;
|
||||||
|
case 'u':
|
||||||
|
// Skip 4 hex chars; emit '?' (no soportamos Unicode escapes).
|
||||||
|
for (int i = 0; i < 4 && p < end; ++i) ++p;
|
||||||
|
out->push_back('?');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
out->push_back(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out->push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse un literal: numero, true, false, null. Avanza `p`. Devuelve raw
|
||||||
|
// (lo que aparece literalmente en el JSON, sin comillas).
|
||||||
|
bool parse_json_literal(const char*& p, const char* end, std::string* out) {
|
||||||
|
out->clear();
|
||||||
|
while (p < end) {
|
||||||
|
char c = *p;
|
||||||
|
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
|
||||||
|
c == '\n' || c == '\r')
|
||||||
|
break;
|
||||||
|
out->push_back(c);
|
||||||
|
++p;
|
||||||
|
}
|
||||||
|
return !out->empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void escape_json_string(const std::string& s, std::string* out) {
|
||||||
|
out->push_back('"');
|
||||||
|
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 buf[8];
|
||||||
|
std::snprintf(buf, sizeof(buf), "\\u%04x", (unsigned)(unsigned char)c);
|
||||||
|
*out += buf;
|
||||||
|
} else {
|
||||||
|
out->push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out->push_back('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsea `["a","b",...]` (array plano de strings). Devuelve true si OK.
|
||||||
|
// Tolerante: si no es un array, devuelve false con `out` vacio.
|
||||||
|
bool parse_string_array(const std::string& src, std::vector<std::string>* out) {
|
||||||
|
out->clear();
|
||||||
|
if (src.empty()) return true;
|
||||||
|
const char* p = src.c_str();
|
||||||
|
const char* end = p + src.size();
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end || *p != '[') return false;
|
||||||
|
++p;
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p < end && *p == ']') return true; // []
|
||||||
|
while (p < end) {
|
||||||
|
skip_ws(p, end);
|
||||||
|
std::string s;
|
||||||
|
if (*p == '"') {
|
||||||
|
if (!parse_json_string(p, end, &s)) return false;
|
||||||
|
} else {
|
||||||
|
if (!parse_json_literal(p, end, &s)) return false;
|
||||||
|
}
|
||||||
|
out->push_back(s);
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end) return false;
|
||||||
|
if (*p == ',') { ++p; continue; }
|
||||||
|
if (*p == ']') { ++p; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsea `{"k":"v","k2":42,"k3":true}` (objeto plano). Devuelve los pares
|
||||||
|
// en orden, con `is_string=true` si el valor venia entre comillas.
|
||||||
|
bool parse_flat_object(const std::string& src, std::vector<MetadataField>* out) {
|
||||||
|
out->clear();
|
||||||
|
if (src.empty()) return true;
|
||||||
|
const char* p = src.c_str();
|
||||||
|
const char* end = p + src.size();
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end || *p != '{') return false;
|
||||||
|
++p;
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p < end && *p == '}') return true; // {}
|
||||||
|
while (p < end) {
|
||||||
|
skip_ws(p, end);
|
||||||
|
std::string key;
|
||||||
|
if (*p != '"' || !parse_json_string(p, end, &key)) return false;
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end || *p != ':') return false;
|
||||||
|
++p;
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end) return false;
|
||||||
|
MetadataField f;
|
||||||
|
f.key = std::move(key);
|
||||||
|
if (*p == '"') {
|
||||||
|
f.is_string = true;
|
||||||
|
if (!parse_json_string(p, end, &f.value_str)) return false;
|
||||||
|
} else if (*p == '{' || *p == '[') {
|
||||||
|
// Objeto/array anidado: consumir como literal hasta el cierre balanceado.
|
||||||
|
int depth = 0;
|
||||||
|
while (p < end) {
|
||||||
|
char c = *p++;
|
||||||
|
f.value_str.push_back(c);
|
||||||
|
if (c == '{' || c == '[') ++depth;
|
||||||
|
else if (c == '}' || c == ']') {
|
||||||
|
--depth;
|
||||||
|
if (depth == 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.is_string = false;
|
||||||
|
} else {
|
||||||
|
f.is_string = false;
|
||||||
|
if (!parse_json_literal(p, end, &f.value_str)) return false;
|
||||||
|
}
|
||||||
|
out->push_back(std::move(f));
|
||||||
|
skip_ws(p, end);
|
||||||
|
if (p >= end) return false;
|
||||||
|
if (*p == ',') { ++p; continue; }
|
||||||
|
if (*p == '}') { ++p; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void build_string_array(const std::vector<std::string>& xs, std::string* out) {
|
||||||
|
out->push_back('[');
|
||||||
|
for (size_t i = 0; i < xs.size(); ++i) {
|
||||||
|
if (i) out->push_back(',');
|
||||||
|
escape_json_string(xs[i], out);
|
||||||
|
}
|
||||||
|
out->push_back(']');
|
||||||
|
}
|
||||||
|
|
||||||
|
void build_flat_object(const std::vector<MetadataField>& fs, std::string* out) {
|
||||||
|
out->push_back('{');
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& f : fs) {
|
||||||
|
if (f.key.empty()) continue;
|
||||||
|
// Saltar fields con valor vacio para no ensuciar el JSON con
|
||||||
|
// claves "" / null. La excepcion es bool/string vacio explicito —
|
||||||
|
// pero en v1 las claves vacias se omiten.
|
||||||
|
if (f.is_string && f.value_str.empty()) continue;
|
||||||
|
if (!f.is_string && f.value_str.empty()) continue;
|
||||||
|
if (!first) out->push_back(',');
|
||||||
|
first = false;
|
||||||
|
escape_json_string(f.key, out);
|
||||||
|
out->push_back(':');
|
||||||
|
if (f.is_string) {
|
||||||
|
escape_json_string(f.value_str, out);
|
||||||
|
} else {
|
||||||
|
// Validar literal minimamente: si no es number/bool/null/array/object,
|
||||||
|
// re-emitir como string para no producir JSON invalido.
|
||||||
|
const std::string& v = f.value_str;
|
||||||
|
char first_c = v[0];
|
||||||
|
bool ok_literal =
|
||||||
|
(first_c == '-' || (first_c >= '0' && first_c <= '9')) ||
|
||||||
|
v == "true" || v == "false" || v == "null" ||
|
||||||
|
first_c == '{' || first_c == '[';
|
||||||
|
if (ok_literal) {
|
||||||
|
*out += v;
|
||||||
|
} else {
|
||||||
|
escape_json_string(v, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out->push_back('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool entity_load_full(const char* db_path, const char* id, EntityRecord* out) {
|
||||||
|
if (!db_path || !id || !out) return false;
|
||||||
|
*out = EntityRecord{};
|
||||||
|
out->id = id;
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) {
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_stmt* st = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"SELECT name, type_ref, description, status, tags, metadata "
|
||||||
|
"FROM entities WHERE id = ?";
|
||||||
|
if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) {
|
||||||
|
sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT);
|
||||||
|
bool ok = false;
|
||||||
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||||
|
auto col = [&](int i) {
|
||||||
|
const unsigned char* p = sqlite3_column_text(st, i);
|
||||||
|
return std::string(p ? (const char*)p : "");
|
||||||
|
};
|
||||||
|
out->name = col(0);
|
||||||
|
out->type_ref = col(1);
|
||||||
|
out->description = col(2);
|
||||||
|
out->status = col(3);
|
||||||
|
std::string tags_json = col(4);
|
||||||
|
std::string meta_json = col(5);
|
||||||
|
parse_string_array(tags_json, &out->tags);
|
||||||
|
parse_flat_object(meta_json, &out->metadata);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
sqlite3_close(db);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool entity_update(const char* db_path, const EntityRecord& rec) {
|
||||||
|
if (!db_path || rec.id.empty()) return false;
|
||||||
|
sqlite3* db = nullptr;
|
||||||
|
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) {
|
||||||
|
if (db) sqlite3_close(db);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string tags_json;
|
||||||
|
std::string meta_json;
|
||||||
|
build_string_array(rec.tags, &tags_json);
|
||||||
|
build_flat_object(rec.metadata, &meta_json);
|
||||||
|
std::string ts = now_iso();
|
||||||
|
std::string status = rec.status.empty() ? std::string("active") : rec.status;
|
||||||
|
|
||||||
|
const char* sql =
|
||||||
|
"UPDATE entities SET name = ?, type_ref = ?, description = ?, "
|
||||||
|
"status = ?, tags = ?, metadata = ?, updated_at = ? WHERE id = ?";
|
||||||
|
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, rec.name.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 2, rec.type_ref.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 3, rec.description.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 4, status.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 5, tags_json.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 6, meta_json.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 7, ts.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(st, 8, rec.id.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
bool ok = sqlite3_step(st) == SQLITE_DONE;
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
sqlite3_close(db);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool entity_list_distinct_tags(const char* db_path,
|
||||||
|
std::vector<std::string>* 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;
|
||||||
|
}
|
||||||
|
// json_each esta disponible (SQLITE_ENABLE_JSON1 — ver CMakeLists del
|
||||||
|
// amalgamation). Solo strings como tag (kind = 'text').
|
||||||
|
const char* sql =
|
||||||
|
"SELECT DISTINCT json_each.value FROM entities, json_each(entities.tags) "
|
||||||
|
"WHERE json_each.type = 'text' ORDER BY json_each.value";
|
||||||
|
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) {
|
||||||
|
const unsigned char* p = sqlite3_column_text(st, 0);
|
||||||
|
if (p && *p) out->emplace_back((const char*)p);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(st);
|
||||||
|
sqlite3_close(db);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Index user_data -> sql id
|
// Index user_data -> sql id
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
// Operaciones CRUD sobre operations.db (entities + relations) y deteccion
|
// Operaciones CRUD sobre operations.db (entities + relations) y deteccion
|
||||||
// heuristica de tipo a partir de texto libre. Pensado para que la toolbar y
|
// heuristica de tipo a partir de texto libre. Pensado para que la toolbar y
|
||||||
@@ -58,6 +60,45 @@ bool entity_get_notes(const char* db_path, const char* id, std::string* out);
|
|||||||
// Sobrescribe `notes` con el contenido proporcionado. Toca `updated_at`.
|
// Sobrescribe `notes` con el contenido proporcionado. Toca `updated_at`.
|
||||||
bool entity_set_notes(const char* db_path, const char* id, const char* notes);
|
bool entity_set_notes(const char* db_path, const char* id, const char* notes);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Inspector editable (issue 0008)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Un campo de la columna `metadata` JSON. `is_string=true` se emite como
|
||||||
|
// "..." en JSON; false se emite como literal (number/bool/null). El parser
|
||||||
|
// rellena esto al leer; el caller puede sobreescribirlo basado en el schema
|
||||||
|
// del tipo antes de guardar.
|
||||||
|
struct MetadataField {
|
||||||
|
std::string key;
|
||||||
|
std::string value_str;
|
||||||
|
bool is_string = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot completo de los campos editables de una entidad. No incluye
|
||||||
|
// `notes` (panel separado) ni `created_at` (no editable).
|
||||||
|
struct EntityRecord {
|
||||||
|
std::string id;
|
||||||
|
std::string name;
|
||||||
|
std::string type_ref;
|
||||||
|
std::string description;
|
||||||
|
std::string status; // active|stale|corrupted|archived
|
||||||
|
std::vector<std::string> tags;
|
||||||
|
std::vector<MetadataField> metadata; // orden preservado del JSON original
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carga el snapshot editable de la entidad. Devuelve false si no existe o
|
||||||
|
// SQLite falla. Tags y metadata se parsean desde JSON.
|
||||||
|
bool entity_load_full(const char* db_path, const char* id, EntityRecord* out);
|
||||||
|
|
||||||
|
// Persiste el snapshot. Toca `updated_at`. Tags y metadata se serializan a
|
||||||
|
// JSON. Devuelve false si SQLite falla.
|
||||||
|
bool entity_update(const char* db_path, const EntityRecord& rec);
|
||||||
|
|
||||||
|
// Lista las tags distintas en uso en toda la BD (para autocomplete del
|
||||||
|
// chip-input del Inspector). Sin orden particular.
|
||||||
|
bool entity_list_distinct_tags(const char* db_path,
|
||||||
|
std::vector<std::string>* out);
|
||||||
|
|
||||||
// Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada
|
// Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada
|
||||||
// carga del grafo (graph_sources usa FNV1a sobre id como user_data).
|
// carga del grafo (graph_sources usa FNV1a sobre id como user_data).
|
||||||
struct EntityIndex {
|
struct EntityIndex {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: 0008
|
id: 0008
|
||||||
title: Inspector editable — campos del schema + tags + extras
|
title: Inspector editable — campos del schema + tags + extras
|
||||||
status: pending
|
status: completed
|
||||||
priority: high
|
priority: high
|
||||||
created: 2026-04-30
|
created: 2026-04-30
|
||||||
|
completed: 2026-05-01
|
||||||
depends_on: [0005]
|
depends_on: [0005]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,6 +153,8 @@ static bool switch_to_project(const std::string& slug) {
|
|||||||
|
|
||||||
// Aplica paths nuevos y abre BDs
|
// Aplica paths nuevos y abre BDs
|
||||||
apply_project_paths(slug);
|
apply_project_paths(slug);
|
||||||
|
ge::views_inspector_clear_draft(g_app);
|
||||||
|
g_app.parsed_types = ge::ParsedTypes{};
|
||||||
if (!ge::layout_store_open(g_layout_db_path.c_str())) {
|
if (!ge::layout_store_open(g_layout_db_path.c_str())) {
|
||||||
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
|
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
|
||||||
g_layout_db_path.c_str());
|
g_layout_db_path.c_str());
|
||||||
@@ -200,6 +202,8 @@ static bool load_input() {
|
|||||||
" %zu relations, %zu icons\n",
|
" %zu relations, %zu icons\n",
|
||||||
pt.entities.size(), with_schema, total_fields,
|
pt.entities.size(), with_schema, total_fields,
|
||||||
pt.relations.size(), codepoints.size());
|
pt.relations.size(), codepoints.size());
|
||||||
|
// Stash en AppState para que el Inspector resuelva schemas (issue 0008).
|
||||||
|
g_app.parsed_types = std::move(pt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +247,12 @@ static bool load_input() {
|
|||||||
ge::views_reset_visibility(g_app);
|
ge::views_reset_visibility(g_app);
|
||||||
ge::views_apply_visibility(g_app);
|
ge::views_apply_visibility(g_app);
|
||||||
|
|
||||||
|
// 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)
|
// --layout inicial (si llego del CLI)
|
||||||
int idx = layout_name_to_index(g_layout_initial);
|
int idx = layout_name_to_index(g_layout_initial);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
@@ -638,6 +648,68 @@ static void render() {
|
|||||||
g_app.want_change_type = false;
|
g_app.want_change_type = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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)) {
|
||||||
|
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
|
// Reset layout: limpia NF_PINNED en todos los nodos. El layout activo se
|
||||||
// reaplica via apply_layout_tick (la toolbar ya lo incrementa).
|
// reaplica via apply_layout_tick (la toolbar ya lo incrementa).
|
||||||
if (g_app.want_unpin_all) {
|
if (g_app.want_unpin_all) {
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cfloat>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
namespace ge {
|
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) {
|
void views_inspector(AppState& app) {
|
||||||
@@ -302,14 +490,35 @@ void views_inspector(AppState& app) {
|
|||||||
const auto& sel = app.viewport->selection;
|
const auto& sel = app.viewport->selection;
|
||||||
|
|
||||||
if (sel.empty()) {
|
if (sel.empty()) {
|
||||||
|
if (app.insp_node_idx != -1 && !app.insp_dirty) {
|
||||||
|
views_inspector_clear_draft(app);
|
||||||
|
}
|
||||||
ImGui::TextUnformatted("No selection.");
|
ImGui::TextUnformatted("No selection.");
|
||||||
ImGui::TextWrapped("Click a node, or shift+drag to lasso a region.");
|
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();
|
ImGui::End();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sel.size() > 1) {
|
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::Text("%zu nodes selected", sel.size());
|
||||||
|
ImGui::TextDisabled("(edicion multi-seleccion no soportada)");
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
for (size_t i = 0; i < sel.size() && i < 32; ++i) {
|
for (size_t i = 0; i < sel.size() && i < 32; ++i) {
|
||||||
int idx = sel[i];
|
int idx = sel[i];
|
||||||
@@ -329,28 +538,335 @@ void views_inspector(AppState& app) {
|
|||||||
ImGui::End();
|
ImGui::End();
|
||||||
return;
|
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 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::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::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::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);
|
// ---- Tags ----
|
||||||
ImGui::TextUnformatted("type:");
|
ImGui::Spacing();
|
||||||
ImGui::PopStyleColor();
|
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::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);
|
// ---- Footer: Save / Discard / Notes / Neighbors ----
|
||||||
ImGui::Text("idx=%d user_data=%llx pos=(%.1f, %.1f)",
|
ImGui::Spacing();
|
||||||
idx, (unsigned long long)n.user_data, n.x, n.y);
|
ImGui::Separator();
|
||||||
ImGui::PopStyleColor();
|
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::Spacing();
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
|
||||||
ImGui::TextUnformatted("Neighbors:");
|
ImGui::TextUnformatted("Neighbors:");
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "types_registry.h"
|
||||||
|
#include "entity_ops.h"
|
||||||
|
|
||||||
struct GraphData;
|
struct GraphData;
|
||||||
struct GraphViewportState;
|
struct GraphViewportState;
|
||||||
|
|
||||||
@@ -96,6 +99,43 @@ struct AppState {
|
|||||||
bool want_save_note = false;
|
bool want_save_note = false;
|
||||||
bool want_open_note = false; // doble click → cargar y abrir
|
bool want_open_note = false; // doble click → cargar y abrir
|
||||||
int open_note_target = -1; // node_idx a 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).
|
// 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.
|
// los tipos del grafo activo. Llamar tras cargar/recargar el grafo.
|
||||||
void views_reset_visibility(AppState& app);
|
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
|
} // namespace ge
|
||||||
|
|||||||
Reference in New Issue
Block a user