merge: issue/0006-projects-subfolders — sistema de proyectos dentro de la app
Cada proyecto es una subcarpeta junto al exe con su operations.db, types.yaml y graph_explorer.db. Switcher en la toolbar con New / Open / Recent / Reveal. Migracion automatica del layout legacy a projects/default/. Cierra issue 0006.
This commit is contained in:
@@ -21,6 +21,7 @@ add_imgui_app(graph_explorer
|
||||
types_registry.cpp
|
||||
layout_store.cpp
|
||||
entity_ops.cpp
|
||||
project_manager.cpp
|
||||
# --- viz ---
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp
|
||||
${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
id: 0006
|
||||
title: Sistema de proyectos dentro de la app — subcarpetas + switcher
|
||||
status: pending
|
||||
status: completed
|
||||
priority: high
|
||||
created: 2026-04-30
|
||||
completed: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "types_registry.h"
|
||||
#include "layout_store.h"
|
||||
#include "entity_ops.h"
|
||||
#include "project_manager.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -48,6 +49,12 @@ static std::string g_layout_initial; // --layout flag
|
||||
static uint64_t g_graph_hash = 0;
|
||||
static bool g_loaded = false;
|
||||
|
||||
// Project state — paths derivados del proyecto activo. En modo legacy
|
||||
// (--input/positional explicito), `g_active_project` queda vacio y los paths
|
||||
// vienen del CLI directamente.
|
||||
static std::string g_active_project;
|
||||
static std::string g_layout_db_path; // ruta de graph_explorer.db
|
||||
|
||||
// Force layout GPU context (lazy init).
|
||||
static ForceLayoutGPU* g_gpu_ctx = nullptr;
|
||||
static bool g_gpu_dirty = true;
|
||||
@@ -101,6 +108,60 @@ static void apply_static_layout(int mode) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forward decl — definido mas abajo, lo necesita switch_to_project.
|
||||
static bool load_input();
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Project lifecycle
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Aplica los paths del proyecto `slug` a las globales (g_input_path,
|
||||
// g_types_path, g_layout_db_path) y actualiza g_active_project. No abre BDs
|
||||
// ni carga el grafo — eso lo hace el caller.
|
||||
static void apply_project_paths(const std::string& slug) {
|
||||
ge::ProjectPaths p = ge::project_paths(slug.c_str());
|
||||
g_active_project = slug;
|
||||
g_input_path = p.operations_db;
|
||||
g_types_path = p.types_yaml;
|
||||
g_layout_db_path = p.layout_db;
|
||||
g_app.active_project = slug;
|
||||
}
|
||||
|
||||
// Cambia al proyecto `slug`: cierra layout_store, libera grafo, abre BDs
|
||||
// nuevas, carga grafo, persiste como last_active. Devuelve true en exito.
|
||||
static bool switch_to_project(const std::string& slug) {
|
||||
if (slug.empty()) return false;
|
||||
if (!ge::project_exists(slug.c_str())) {
|
||||
std::fprintf(stderr, "[graph_explorer] project '%s' no existe\n",
|
||||
slug.c_str());
|
||||
return false;
|
||||
}
|
||||
// Cierra estado del proyecto actual
|
||||
ge::layout_store_close();
|
||||
if (g_loaded) {
|
||||
graph::graph_free(&g_graph);
|
||||
g_loaded = false;
|
||||
}
|
||||
if (g_atlas) {
|
||||
graph_icons_destroy(g_atlas);
|
||||
g_atlas = nullptr;
|
||||
}
|
||||
g_atlas_bound = false;
|
||||
g_viewport.selection.clear();
|
||||
g_viewport.hovered_node = -1;
|
||||
g_viewport.selected_node = -1;
|
||||
|
||||
// Aplica paths nuevos y abre BDs
|
||||
apply_project_paths(slug);
|
||||
if (!ge::layout_store_open(g_layout_db_path.c_str())) {
|
||||
std::fprintf(stderr, "[graph_explorer] layout_store_open failed: %s\n",
|
||||
g_layout_db_path.c_str());
|
||||
}
|
||||
bool ok = load_input();
|
||||
if (ok) ge::project_settings_touch(slug.c_str());
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool load_input() {
|
||||
g_input.kind = ge::INPUT_OPERATIONS;
|
||||
g_input.uri = g_input_path.c_str();
|
||||
@@ -424,6 +485,18 @@ static void render() {
|
||||
// Modals
|
||||
ge::views_open_modal(g_app);
|
||||
ge::views_filters_modal(g_app);
|
||||
ge::views_new_project_modal(g_app);
|
||||
|
||||
// Project switch (desde menu Project, o tras crear proyecto nuevo)
|
||||
if (g_app.want_switch_project && !g_app.switch_project_target.empty()) {
|
||||
std::string target = g_app.switch_project_target;
|
||||
g_app.want_switch_project = false;
|
||||
g_app.switch_project_target.clear();
|
||||
if (!switch_to_project(target)) {
|
||||
std::fprintf(stderr,
|
||||
"[graph_explorer] switch_to_project('%s') failed\n", target.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Si el usuario aplico nuevo layout en la toolbar
|
||||
if (g_app.apply_layout_tick > 0) {
|
||||
@@ -685,10 +758,14 @@ static void usage() {
|
||||
"Usage: graph_explorer [<operations.db>]\n"
|
||||
" graph_explorer --input operations <path>\n"
|
||||
" graph_explorer --types <types.yaml>\n"
|
||||
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n");
|
||||
" graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"
|
||||
" graph_explorer --project <slug>\n");
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
bool legacy_mode = false; // --input / positional dado: NO usar proyecto
|
||||
std::string project_arg; // --project <slug> (puede estar vacio)
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const char* a = argv[i];
|
||||
if (std::strcmp(a, "--input") == 0 && i + 2 < argc) {
|
||||
@@ -696,6 +773,7 @@ int main(int argc, char** argv) {
|
||||
const char* path = argv[++i];
|
||||
if (std::strcmp(kind, "operations") == 0) {
|
||||
g_input_path = path;
|
||||
legacy_mode = true;
|
||||
} else {
|
||||
std::fprintf(stderr, "[graph_explorer] unsupported input kind: %s\n", kind);
|
||||
return 1;
|
||||
@@ -704,6 +782,8 @@ int main(int argc, char** argv) {
|
||||
g_types_path = argv[++i];
|
||||
} else if (std::strcmp(a, "--layout") == 0 && i + 1 < argc) {
|
||||
g_layout_initial = argv[++i];
|
||||
} else if (std::strcmp(a, "--project") == 0 && i + 1 < argc) {
|
||||
project_arg = argv[++i];
|
||||
} else if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) {
|
||||
usage();
|
||||
return 0;
|
||||
@@ -712,25 +792,49 @@ int main(int argc, char** argv) {
|
||||
usage();
|
||||
return 1;
|
||||
} else {
|
||||
// Positional: tratado como operations.db
|
||||
if (g_input_path.empty()) g_input_path = a;
|
||||
// Positional: tratado como operations.db (legacy)
|
||||
if (g_input_path.empty()) {
|
||||
g_input_path = a;
|
||||
legacy_mode = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite store junto al exe.
|
||||
ge::layout_store_open("graph_explorer.db");
|
||||
|
||||
// Si no llego --input/positional, intentar operations.db en el cwd
|
||||
// (mismo criterio que graph_explorer.db: relativo al directorio de ejecucion).
|
||||
if (g_input_path.empty()) {
|
||||
if (FILE* f = std::fopen("operations.db", "rb")) {
|
||||
std::fclose(f);
|
||||
g_input_path = "operations.db";
|
||||
std::fprintf(stdout, "[graph_explorer] using default ./operations.db\n");
|
||||
if (legacy_mode) {
|
||||
// Modo legacy: paths sueltos junto al exe (compat con flujo anterior)
|
||||
ge::layout_store_open("graph_explorer.db");
|
||||
g_layout_db_path = "graph_explorer.db";
|
||||
if (!g_input_path.empty()) {
|
||||
load_input();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Modo proyecto: migra layout legacy si aplica, decide proyecto activo,
|
||||
// crea default si no existe ninguno.
|
||||
ge::projects_migrate_legacy_layout();
|
||||
|
||||
if (!g_input_path.empty()) {
|
||||
std::string target = project_arg;
|
||||
if (target.empty()) {
|
||||
ge::ProjectSettings ps;
|
||||
ge::project_settings_load(&ps);
|
||||
target = ps.last_active;
|
||||
}
|
||||
if (target.empty()) target = ge::k_default_project;
|
||||
|
||||
if (!ge::project_exists(target.c_str())) {
|
||||
std::string err;
|
||||
if (!ge::project_create(target.c_str(), &err)) {
|
||||
std::fprintf(stderr,
|
||||
"[graph_explorer] no se pudo crear el proyecto '%s': %s\n",
|
||||
target.c_str(), err.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] proyecto creado: projects/%s/\n", target.c_str());
|
||||
}
|
||||
|
||||
apply_project_paths(target);
|
||||
ge::layout_store_open(g_layout_db_path.c_str());
|
||||
ge::project_settings_touch(target.c_str());
|
||||
load_input();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
#include "project_manager.h"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
#if defined(_WIN32)
|
||||
# include <windows.h>
|
||||
# include <shellapi.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace ge {
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DDL embebido (operations.db schema)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static const char* k_operations_db_ddl = R"SQL(
|
||||
CREATE TABLE IF NOT EXISTS types_snapshot (
|
||||
id TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
lang TEXT NOT NULL,
|
||||
algebraic TEXT NOT NULL CHECK(algebraic IN ('product','sum')),
|
||||
definition TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
snapped_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type_ref TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','stale','corrupted','archived')),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'graph_explorer',
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS relations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
from_entity TEXT NOT NULL DEFAULT '',
|
||||
to_entity TEXT NOT NULL,
|
||||
via TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
purity TEXT NOT NULL DEFAULT '' CHECK(purity IN ('','pure','impure')),
|
||||
direction TEXT NOT NULL DEFAULT 'unidirectional' CHECK(direction IN ('unidirectional','bidirectional','inverse')),
|
||||
weight REAL,
|
||||
status TEXT NOT NULL DEFAULT 'designed' CHECK(status IN ('designed','implemented','tested','running','deprecated')),
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
"order" INTEGER,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS relation_inputs (
|
||||
id TEXT PRIMARY KEY,
|
||||
relation_id TEXT NOT NULL REFERENCES relations(id) ON DELETE CASCADE,
|
||||
entity_id TEXT NOT NULL REFERENCES entities(id),
|
||||
role TEXT NOT NULL,
|
||||
"order" INTEGER
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
||||
id, name, description, tags, domain,
|
||||
content='entities', content_rowid='rowid'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
|
||||
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
|
||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
|
||||
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
|
||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
|
||||
INSERT INTO entities_fts(entities_fts, rowid, id, name, description, tags, domain)
|
||||
VALUES ('delete', old.rowid, old.id, old.name, old.description, old.tags, old.domain);
|
||||
INSERT INTO entities_fts(rowid, id, name, description, tags, domain)
|
||||
VALUES (new.rowid, new.id, new.name, new.description, new.tags, new.domain);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS assertions (
|
||||
id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, rule TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
description TEXT DEFAULT '', active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS assertion_results (
|
||||
id TEXT PRIMARY KEY, assertion_id TEXT NOT NULL,
|
||||
execution_id TEXT DEFAULT '', status TEXT NOT NULL,
|
||||
value TEXT DEFAULT '{}', message TEXT DEFAULT '',
|
||||
evaluated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS executions (
|
||||
id TEXT PRIMARY KEY, pipeline_id TEXT NOT NULL,
|
||||
relation_id TEXT DEFAULT '', status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL, ended_at TEXT DEFAULT '',
|
||||
duration_ms INTEGER DEFAULT 0,
|
||||
records_in INTEGER DEFAULT 0, records_out INTEGER DEFAULT 0,
|
||||
error TEXT DEFAULT '', metrics TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id TEXT PRIMARY KEY, level TEXT NOT NULL DEFAULT 'info',
|
||||
source TEXT DEFAULT '', entity_id TEXT DEFAULT '',
|
||||
execution_id TEXT DEFAULT '', message TEXT NOT NULL,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
)SQL";
|
||||
|
||||
// Semilla types.yaml minima si examples/types.yaml no se encuentra.
|
||||
static const char* k_seed_types_yaml = R"YAML(# types.yaml — generado al crear el proyecto. Editable desde el Type Editor.
|
||||
# Color como "#RRGGBB" (con o sin alpha "#RRGGBBAA").
|
||||
# Shapes: circle | square | diamond | hex | triangle | rounded_square
|
||||
# Iconos: nombres ti-* mapeados en types_registry.cpp
|
||||
# Estilos de relacion: solid | dashed | dotted
|
||||
|
||||
entities:
|
||||
- name: Person
|
||||
color: "#5B8DEF"
|
||||
shape: circle
|
||||
icon: ti-user
|
||||
|
||||
- name: Email
|
||||
color: "#58CA8C"
|
||||
shape: square
|
||||
icon: ti-mail
|
||||
|
||||
- name: Domain
|
||||
color: "#F4B860"
|
||||
shape: diamond
|
||||
icon: ti-world
|
||||
|
||||
- name: Phone
|
||||
color: "#E36AC0"
|
||||
shape: hex
|
||||
icon: ti-phone
|
||||
|
||||
- name: Org
|
||||
color: "#C780E8"
|
||||
shape: triangle
|
||||
icon: ti-building
|
||||
|
||||
- name: Document
|
||||
color: "#C9C9C9"
|
||||
shape: square
|
||||
icon: ti-file
|
||||
|
||||
relations:
|
||||
- name: owns
|
||||
color: "#888888"
|
||||
style: solid
|
||||
|
||||
- name: knows
|
||||
color: "#AAAAAA"
|
||||
style: solid
|
||||
|
||||
- name: located_in
|
||||
color: "#FFB870"
|
||||
style: dashed
|
||||
)YAML";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Slug / paths
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool project_validate_slug(const char* name, std::string* error_msg) {
|
||||
if (!name || !*name) {
|
||||
if (error_msg) *error_msg = "slug vacio";
|
||||
return false;
|
||||
}
|
||||
size_t n = std::strlen(name);
|
||||
if (n > 64) {
|
||||
if (error_msg) *error_msg = "slug demasiado largo (max 64)";
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
char c = name[i];
|
||||
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
|| c == '_' || c == '-';
|
||||
if (!ok) {
|
||||
if (error_msg) {
|
||||
*error_msg = "caracter no permitido (use a-z, 0-9, _ , -): '";
|
||||
error_msg->push_back(c);
|
||||
error_msg->push_back('\'');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ProjectPaths project_paths(const char* slug) {
|
||||
ProjectPaths p;
|
||||
if (!slug || !*slug) return p;
|
||||
fs::path root = fs::path(k_projects_dir) / slug;
|
||||
p.root_dir = root.string();
|
||||
p.operations_db = (root / "operations.db").string();
|
||||
p.types_yaml = (root / "types.yaml").string();
|
||||
p.layout_db = (root / "graph_explorer.db").string();
|
||||
return p;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Listing / existence
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool projects_root_ensure() {
|
||||
std::error_code ec;
|
||||
fs::create_directories(k_projects_dir, ec);
|
||||
return !ec;
|
||||
}
|
||||
|
||||
bool project_list(std::vector<std::string>* out) {
|
||||
if (!out) return false;
|
||||
out->clear();
|
||||
std::error_code ec;
|
||||
if (!fs::exists(k_projects_dir, ec)) return true;
|
||||
for (auto& e : fs::directory_iterator(k_projects_dir, ec)) {
|
||||
if (ec) break;
|
||||
if (!e.is_directory()) continue;
|
||||
std::string slug = e.path().filename().string();
|
||||
std::string err;
|
||||
if (!project_validate_slug(slug.c_str(), &err)) continue;
|
||||
out->push_back(slug);
|
||||
}
|
||||
std::sort(out->begin(), out->end());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool project_exists(const char* slug) {
|
||||
if (!slug || !*slug) return false;
|
||||
ProjectPaths p = project_paths(slug);
|
||||
std::error_code ec;
|
||||
return fs::exists(p.operations_db, ec);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Bootstrap operations.db con el DDL embebido
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static bool bootstrap_operations_db(const std::string& path, std::string* error_msg) {
|
||||
sqlite3* db = nullptr;
|
||||
int rc = sqlite3_open_v2(path.c_str(), &db,
|
||||
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (error_msg) {
|
||||
*error_msg = "sqlite3_open failed: ";
|
||||
*error_msg += db ? sqlite3_errmsg(db) : sqlite3_errstr(rc);
|
||||
}
|
||||
if (db) sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
char* errmsg = nullptr;
|
||||
rc = sqlite3_exec(db, k_operations_db_ddl, nullptr, nullptr, &errmsg);
|
||||
if (rc != SQLITE_OK) {
|
||||
if (error_msg) {
|
||||
*error_msg = "DDL exec failed: ";
|
||||
*error_msg += errmsg ? errmsg : "(unknown)";
|
||||
}
|
||||
if (errmsg) sqlite3_free(errmsg);
|
||||
sqlite3_close(db);
|
||||
return false;
|
||||
}
|
||||
sqlite3_close(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// project_create
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool project_create(const char* slug, std::string* error_msg) {
|
||||
std::string err;
|
||||
if (!project_validate_slug(slug, &err)) {
|
||||
if (error_msg) *error_msg = err;
|
||||
return false;
|
||||
}
|
||||
if (project_exists(slug)) {
|
||||
if (error_msg) *error_msg = "el proyecto ya existe";
|
||||
return false;
|
||||
}
|
||||
if (!projects_root_ensure()) {
|
||||
if (error_msg) *error_msg = "no se pudo crear el directorio projects/";
|
||||
return false;
|
||||
}
|
||||
|
||||
ProjectPaths p = project_paths(slug);
|
||||
std::error_code ec;
|
||||
fs::create_directories(p.root_dir, ec);
|
||||
if (ec) {
|
||||
if (error_msg) *error_msg = "mkdir falló: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!bootstrap_operations_db(p.operations_db, error_msg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// types.yaml: copia desde examples/types.yaml si existe, si no usa embed.
|
||||
std::error_code ec2;
|
||||
bool wrote_yaml = false;
|
||||
if (fs::exists("examples/types.yaml", ec2)) {
|
||||
fs::copy_file("examples/types.yaml", p.types_yaml,
|
||||
fs::copy_options::overwrite_existing, ec2);
|
||||
if (!ec2) wrote_yaml = true;
|
||||
}
|
||||
if (!wrote_yaml) {
|
||||
std::ofstream out(p.types_yaml);
|
||||
if (!out) {
|
||||
if (error_msg) *error_msg = "no se pudo escribir types.yaml";
|
||||
return false;
|
||||
}
|
||||
out << k_seed_types_yaml;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Migracion legacy (mover operations.db del cwd a projects/default/)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool projects_migrate_legacy_layout() {
|
||||
std::error_code ec;
|
||||
if (fs::exists(k_projects_dir, ec)) return true; // ya migrado o creado
|
||||
|
||||
bool has_operations = fs::exists("operations.db", ec);
|
||||
bool has_layout = fs::exists("graph_explorer.db", ec);
|
||||
if (!has_operations && !has_layout) return true; // nada que migrar
|
||||
|
||||
if (!projects_root_ensure()) return false;
|
||||
ProjectPaths p = project_paths(k_default_project);
|
||||
fs::create_directories(p.root_dir, ec);
|
||||
if (ec) return false;
|
||||
|
||||
if (has_operations) {
|
||||
fs::rename("operations.db", p.operations_db, ec);
|
||||
if (ec) {
|
||||
// fallback: copia + borra
|
||||
fs::copy_file("operations.db", p.operations_db,
|
||||
fs::copy_options::overwrite_existing, ec);
|
||||
if (!ec) fs::remove("operations.db", ec);
|
||||
}
|
||||
}
|
||||
if (has_layout) {
|
||||
std::error_code ec3;
|
||||
fs::rename("graph_explorer.db", p.layout_db, ec3);
|
||||
if (ec3) {
|
||||
fs::copy_file("graph_explorer.db", p.layout_db,
|
||||
fs::copy_options::overwrite_existing, ec3);
|
||||
if (!ec3) fs::remove("graph_explorer.db", ec3);
|
||||
}
|
||||
}
|
||||
// semilla types.yaml para el proyecto default
|
||||
if (!fs::exists(p.types_yaml, ec)) {
|
||||
std::error_code ec4;
|
||||
if (fs::exists("examples/types.yaml", ec4)) {
|
||||
fs::copy_file("examples/types.yaml", p.types_yaml,
|
||||
fs::copy_options::overwrite_existing, ec4);
|
||||
} else {
|
||||
std::ofstream out(p.types_yaml);
|
||||
if (out) out << k_seed_types_yaml;
|
||||
}
|
||||
}
|
||||
std::fprintf(stdout,
|
||||
"[graph_explorer] migrated legacy layout to projects/%s/\n",
|
||||
k_default_project);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Settings (graph_explorer.ini)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
size_t a = 0, b = s.size();
|
||||
while (a < b && std::isspace((unsigned char)s[a])) ++a;
|
||||
while (b > a && std::isspace((unsigned char)s[b - 1])) --b;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
static std::vector<std::string> split_csv(const std::string& s) {
|
||||
std::vector<std::string> out;
|
||||
std::string cur;
|
||||
for (char c : s) {
|
||||
if (c == ',') {
|
||||
std::string t = trim(cur);
|
||||
if (!t.empty()) out.push_back(t);
|
||||
cur.clear();
|
||||
} else {
|
||||
cur.push_back(c);
|
||||
}
|
||||
}
|
||||
std::string t = trim(cur);
|
||||
if (!t.empty()) out.push_back(t);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool project_settings_load(ProjectSettings* out) {
|
||||
if (!out) return false;
|
||||
*out = {};
|
||||
std::ifstream in(k_settings_file);
|
||||
if (!in) return true; // no es error: archivo aun no existe
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
std::string t = trim(line);
|
||||
if (t.empty() || t[0] == '#') continue;
|
||||
size_t eq = t.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
std::string key = trim(t.substr(0, eq));
|
||||
std::string val = trim(t.substr(eq + 1));
|
||||
if (key == "last_active") out->last_active = val;
|
||||
else if (key == "recent") out->recent = split_csv(val);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool project_settings_save(const ProjectSettings& s) {
|
||||
std::ofstream out(k_settings_file);
|
||||
if (!out) return false;
|
||||
out << "# graph_explorer.ini — autogenerado, editable\n";
|
||||
out << "last_active = " << s.last_active << "\n";
|
||||
out << "recent = ";
|
||||
for (size_t i = 0; i < s.recent.size(); ++i) {
|
||||
if (i) out << ",";
|
||||
out << s.recent[i];
|
||||
}
|
||||
out << "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
void project_settings_touch(const char* slug) {
|
||||
if (!slug || !*slug) return;
|
||||
ProjectSettings s;
|
||||
project_settings_load(&s);
|
||||
s.last_active = slug;
|
||||
// Push al frente, dedup, max 5
|
||||
std::vector<std::string> n;
|
||||
n.reserve(6);
|
||||
n.emplace_back(slug);
|
||||
for (const auto& r : s.recent) {
|
||||
if (r == slug) continue;
|
||||
n.push_back(r);
|
||||
if (n.size() >= 5) break;
|
||||
}
|
||||
s.recent = std::move(n);
|
||||
project_settings_save(s);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Reveal in explorer
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
void project_reveal_in_explorer(const char* slug) {
|
||||
if (!slug || !*slug) return;
|
||||
ProjectPaths p = project_paths(slug);
|
||||
std::error_code ec;
|
||||
if (!fs::exists(p.root_dir, ec)) return;
|
||||
std::string abs = fs::absolute(p.root_dir, ec).string();
|
||||
|
||||
#if defined(_WIN32)
|
||||
// Reemplaza '/' por '\\' para que ShellExecute reconozca el path.
|
||||
for (char& c : abs) if (c == '/') c = '\\';
|
||||
ShellExecuteA(nullptr, "open", abs.c_str(), nullptr, nullptr, SW_SHOWDEFAULT);
|
||||
#else
|
||||
std::string cmd = "xdg-open '";
|
||||
cmd += abs;
|
||||
cmd += "' >/dev/null 2>&1 &";
|
||||
int rc = std::system(cmd.c_str());
|
||||
(void)rc;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace ge
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Sistema de proyectos: cada proyecto es una subcarpeta junto al exe que
|
||||
// contiene sus propios `operations.db`, `types.yaml` y `graph_explorer.db`
|
||||
// (layouts). El usuario crea proyectos, los nombra y conmuta entre ellos.
|
||||
//
|
||||
// Layout en disco:
|
||||
//
|
||||
// <exe_dir>/
|
||||
// graph_explorer.ini # last_active + recent (gestionado aqui)
|
||||
// projects/
|
||||
// default/
|
||||
// operations.db # bootstrap con DDL completo
|
||||
// types.yaml # copia editable; semilla = ./examples/types.yaml o embed
|
||||
// graph_explorer.db # layouts del proyecto
|
||||
// caso_X/
|
||||
// ...
|
||||
|
||||
namespace ge {
|
||||
|
||||
constexpr const char* k_projects_dir = "projects";
|
||||
constexpr const char* k_default_project = "default";
|
||||
constexpr const char* k_settings_file = "graph_explorer.ini";
|
||||
|
||||
struct ProjectPaths {
|
||||
std::string root_dir; // <exe>/projects/<slug>/
|
||||
std::string operations_db; // <root>/operations.db
|
||||
std::string types_yaml; // <root>/types.yaml
|
||||
std::string layout_db; // <root>/graph_explorer.db
|
||||
};
|
||||
|
||||
// Valida slug (a-z, 0-9, _ y -). Devuelve true si es valido. error_msg
|
||||
// describe el motivo del fallo.
|
||||
bool project_validate_slug(const char* name, std::string* error_msg);
|
||||
|
||||
// Devuelve los paths del proyecto `slug`. NO comprueba existencia en disco.
|
||||
ProjectPaths project_paths(const char* slug);
|
||||
|
||||
// Asegura que `<exe>/projects/` existe. Idempotente.
|
||||
bool projects_root_ensure();
|
||||
|
||||
// Lista proyectos detectados (subdirs de `<exe>/projects/`). Ordenado alfa.
|
||||
// Cada string es el slug (basename de la subcarpeta).
|
||||
bool project_list(std::vector<std::string>* out);
|
||||
|
||||
// Devuelve true si la carpeta del proyecto existe y contiene operations.db.
|
||||
bool project_exists(const char* slug);
|
||||
|
||||
// Crea el proyecto `slug`:
|
||||
// - mkdir <exe>/projects/<slug>/
|
||||
// - bootstrap operations.db con el DDL completo (entities, relations, fts, ...)
|
||||
// - escribe types.yaml semilla (basado en examples/types.yaml o embed)
|
||||
// Devuelve false y rellena `error_msg` si falla.
|
||||
bool project_create(const char* slug, std::string* error_msg);
|
||||
|
||||
// Migracion: si `<exe>/projects/` no existe pero hay operations.db en el cwd,
|
||||
// crea projects/default/ y mueve operations.db + graph_explorer.db ahi.
|
||||
// Idempotente: no-op si projects/ ya existe.
|
||||
bool projects_migrate_legacy_layout();
|
||||
|
||||
// Lee/escribe `graph_explorer.ini` (junto al exe). Formato:
|
||||
// last_active = <slug>
|
||||
// recent = slug1,slug2,slug3
|
||||
struct ProjectSettings {
|
||||
std::string last_active; // slug del ultimo proyecto abierto
|
||||
std::vector<std::string> recent; // mas reciente primero, max 5
|
||||
};
|
||||
|
||||
bool project_settings_load(ProjectSettings* out);
|
||||
bool project_settings_save(const ProjectSettings& s);
|
||||
|
||||
// Marca `slug` como activo y lo empuja al frente de `recent`. Persiste en
|
||||
// disco. No-op si slug es vacio.
|
||||
void project_settings_touch(const char* slug);
|
||||
|
||||
// Abre la carpeta del proyecto en el explorador del sistema.
|
||||
// (Windows: explorer.exe; Linux: xdg-open).
|
||||
void project_reveal_in_explorer(const char* slug);
|
||||
|
||||
} // namespace ge
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "views.h"
|
||||
#include "entity_ops.h"
|
||||
#include "project_manager.h"
|
||||
|
||||
#include "viz/graph_types.h"
|
||||
#include "viz/graph_viewport.h"
|
||||
@@ -91,6 +92,74 @@ void views_apply_visibility(AppState& app) {
|
||||
void views_toolbar(AppState& app) {
|
||||
using namespace fn_ui;
|
||||
toolbar_begin();
|
||||
// Project switcher — etiqueta = proyecto activo. Click abre popup con
|
||||
// New / Open / Recent / Reveal. Si no hay proyecto activo (modo
|
||||
// legacy con --input directo), muestra "(no project)".
|
||||
{
|
||||
char btn_label[128];
|
||||
const char* p = app.active_project.empty()
|
||||
? "(no project)" : app.active_project.c_str();
|
||||
std::snprintf(btn_label, sizeof(btn_label), TI_FOLDER " Project: %s", p);
|
||||
if (button(btn_label, ButtonVariant::Secondary)) {
|
||||
// Refresca caches al abrir
|
||||
project_list(&app.project_list_cache);
|
||||
ProjectSettings ps;
|
||||
project_settings_load(&ps);
|
||||
app.project_recent_cache = ps.recent;
|
||||
ImGui::OpenPopup("##project_menu");
|
||||
}
|
||||
if (ImGui::BeginPopup("##project_menu")) {
|
||||
if (ImGui::MenuItem(TI_PLUS " New project...")) {
|
||||
app.show_new_project_modal = true;
|
||||
app.new_project_buf[0] = 0;
|
||||
app.new_project_error.clear();
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
// Recent submenu
|
||||
if (!app.project_recent_cache.empty()) {
|
||||
if (ImGui::BeginMenu("Recent")) {
|
||||
for (const auto& slug : app.project_recent_cache) {
|
||||
bool is_active = (slug == app.active_project);
|
||||
if (ImGui::MenuItem(slug.c_str(), nullptr, is_active)
|
||||
&& !is_active) {
|
||||
app.want_switch_project = true;
|
||||
app.switch_project_target = slug;
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Open submenu — todos los detectados
|
||||
if (ImGui::BeginMenu("Open")) {
|
||||
if (app.project_list_cache.empty()) {
|
||||
ImGui::TextDisabled("(no projects yet)");
|
||||
} else {
|
||||
for (const auto& slug : app.project_list_cache) {
|
||||
bool is_active = (slug == app.active_project);
|
||||
if (ImGui::MenuItem(slug.c_str(), nullptr, is_active)
|
||||
&& !is_active) {
|
||||
app.want_switch_project = true;
|
||||
app.switch_project_target = slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
bool can_reveal = !app.active_project.empty();
|
||||
if (ImGui::MenuItem(TI_FOLDER_OPEN " Reveal in explorer",
|
||||
nullptr, false, can_reveal)) {
|
||||
project_reveal_in_explorer(app.active_project.c_str());
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
toolbar_separator();
|
||||
|
||||
if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) {
|
||||
app.show_open_modal = true;
|
||||
}
|
||||
@@ -490,6 +559,58 @@ bool views_filters_modal(AppState& app) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Modal: New project
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
bool views_new_project_modal(AppState& app) {
|
||||
if (!app.show_new_project_modal) return false;
|
||||
bool created = false;
|
||||
if (fn_ui::modal_dialog_begin("New project", &app.show_new_project_modal,
|
||||
ImVec2(480, 0))) {
|
||||
ImGui::TextWrapped(
|
||||
"Crea una subcarpeta en projects/ con su propio operations.db,"
|
||||
" types.yaml y graph_explorer.db.");
|
||||
ImGui::Spacing();
|
||||
fn_ui::text_input("Slug", app.new_project_buf, sizeof(app.new_project_buf),
|
||||
"caso_aurgi");
|
||||
ImGui::TextDisabled("a-z, 0-9, '_' y '-' (max 64)");
|
||||
|
||||
if (!app.new_project_error.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.4f, 1.0f));
|
||||
ImGui::TextWrapped("%s", app.new_project_error.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
if (fn_ui::button("Create", fn_ui::ButtonVariant::Primary)) {
|
||||
std::string err;
|
||||
if (!project_validate_slug(app.new_project_buf, &err)) {
|
||||
app.new_project_error = err;
|
||||
} else if (project_exists(app.new_project_buf)) {
|
||||
app.new_project_error = "ya existe un proyecto con ese slug";
|
||||
} else if (!project_create(app.new_project_buf, &err)) {
|
||||
app.new_project_error = err;
|
||||
} else {
|
||||
// Switch al recien creado
|
||||
app.want_switch_project = true;
|
||||
app.switch_project_target = app.new_project_buf;
|
||||
app.show_new_project_modal = false;
|
||||
app.new_project_error.clear();
|
||||
created = true;
|
||||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) {
|
||||
app.show_new_project_modal = false;
|
||||
app.new_project_error.clear();
|
||||
}
|
||||
}
|
||||
fn_ui::modal_dialog_end();
|
||||
return created;
|
||||
}
|
||||
|
||||
bool views_open_modal(AppState& app) {
|
||||
if (!app.show_open_modal) return false;
|
||||
bool opened = false;
|
||||
|
||||
@@ -52,6 +52,16 @@ struct AppState {
|
||||
bool want_open_file = false; // marcado al confirmar el modal Open
|
||||
char open_buf[512] = {};
|
||||
|
||||
// Project system (issue 0006)
|
||||
std::string active_project; // slug del proyecto activo
|
||||
bool want_switch_project = false;
|
||||
std::string switch_project_target; // slug objetivo del switch
|
||||
bool show_new_project_modal = false;
|
||||
char new_project_buf[80] = {};
|
||||
std::string new_project_error; // mensaje a mostrar en el modal
|
||||
std::vector<std::string> project_list_cache; // refrescado al abrir el menu Project
|
||||
std::vector<std::string> project_recent_cache;
|
||||
|
||||
// Labels overlay
|
||||
bool labels_enabled = true;
|
||||
|
||||
@@ -108,6 +118,10 @@ bool views_filters_modal(AppState& app);
|
||||
// Modal Open file — text input + boton Open.
|
||||
bool views_open_modal(AppState& app);
|
||||
|
||||
// Modal "New project..." — slug input con validacion. Devuelve true si el
|
||||
// usuario confirma con un slug valido.
|
||||
bool views_new_project_modal(AppState& app);
|
||||
|
||||
// Refresca los flags `flags` de cada nodo/arista segun el array
|
||||
// `type_visible[]` / `rel_type_visible[]`. Lineal en N+M.
|
||||
void views_apply_visibility(AppState& app);
|
||||
|
||||
Reference in New Issue
Block a user