diff --git a/CMakeLists.txt b/CMakeLists.txt index 2756bc2..9674d8a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/issues/0006-projects-subfolders.md b/issues/completed/0006-projects-subfolders.md similarity index 98% rename from issues/0006-projects-subfolders.md rename to issues/completed/0006-projects-subfolders.md index 1a50513..d9ba16d 100644 --- a/issues/0006-projects-subfolders.md +++ b/issues/completed/0006-projects-subfolders.md @@ -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 diff --git a/main.cpp b/main.cpp index a93b2b4..f32a1b8 100644 --- a/main.cpp +++ b/main.cpp @@ -24,6 +24,7 @@ #include "types_registry.h" #include "layout_store.h" #include "entity_ops.h" +#include "project_manager.h" #include #include @@ -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 []\n" " graph_explorer --input operations \n" " graph_explorer --types \n" - " graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n"); + " graph_explorer --layout force|grid|circular|radial|hierarchical|fixed\n" + " graph_explorer --project \n"); } int main(int argc, char** argv) { + bool legacy_mode = false; // --input / positional dado: NO usar proyecto + std::string project_arg; // --project (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(); } diff --git a/project_manager.cpp b/project_manager.cpp new file mode 100644 index 0000000..59f0fb6 --- /dev/null +++ b/project_manager.cpp @@ -0,0 +1,489 @@ +#include "project_manager.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +# include +# include +#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* 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 split_csv(const std::string& s) { + std::vector 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 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 diff --git a/project_manager.h b/project_manager.h new file mode 100644 index 0000000..4e1330a --- /dev/null +++ b/project_manager.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +// 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: +// +// / +// 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; // /projects// + std::string operations_db; // /operations.db + std::string types_yaml; // /types.yaml + std::string layout_db; // /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 `/projects/` existe. Idempotente. +bool projects_root_ensure(); + +// Lista proyectos detectados (subdirs de `/projects/`). Ordenado alfa. +// Cada string es el slug (basename de la subcarpeta). +bool project_list(std::vector* out); + +// Devuelve true si la carpeta del proyecto existe y contiene operations.db. +bool project_exists(const char* slug); + +// Crea el proyecto `slug`: +// - mkdir /projects// +// - 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 `/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 = +// recent = slug1,slug2,slug3 +struct ProjectSettings { + std::string last_active; // slug del ultimo proyecto abierto + std::vector 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 diff --git a/views.cpp b/views.cpp index 454d3df..4e85ee2 100644 --- a/views.cpp +++ b/views.cpp @@ -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; diff --git a/views.h b/views.h index 03a5683..439b718 100644 --- a/views.h +++ b/views.h @@ -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 project_list_cache; // refrescado al abrir el menu Project + std::vector 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);