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/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);