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:
2026-04-30 23:47:08 +02:00
7 changed files with 829 additions and 16 deletions
+1
View File
@@ -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
+119 -15
View File
@@ -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();
}
+489
View File
@@ -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
+83
View File
@@ -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
+121
View File
@@ -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;
+14
View File
@@ -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);