feat: Settings submenu (Settings.../About...), git column, projects tab

- main.cpp: registrar info About via fn_ui::about_window_set_info
- views.cpp: nueva columna "Git" en la tabla Apps (remote/local/-)
- data.h/cpp + data_http.cpp: AppRow gana repo_url + dir_path
- views.cpp: actions bar (Reindex / + Add / Reload / inbox) y modal Add
- views.cpp: tab Projects con tree + detalle anidado
- data_http.cpp: load_projects_http, load_project_detail_http, http_post_*
- http_client.cpp: SO_RCVTIMEO en Windows como DWORD ms (timeout 5 ms bug)
- CMakeLists: limpieza de srcs duplicados con fn_framework
- app.md: notas operativas y estado actual

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:04:58 +02:00
parent 57d8f0198a
commit a466fff71a
10 changed files with 800 additions and 59 deletions
+241 -3
View File
@@ -135,25 +135,27 @@ bool load_registry_data_http(const std::string& api_url, RegistryData& out) {
// --- Apps ---
out.apps.clear();
j = api_query(cli, "SELECT id, name, lang, domain, description, framework FROM apps ORDER BY name");
j = api_query(cli, "SELECT id, name, lang, domain, description, framework, repo_url, dir_path FROM apps ORDER BY name");
if (!j.is_null() && j.contains("rows")) {
for (auto& row : j["rows"]) {
AppRow r;
r.id = extract_str(row, 0); r.name = extract_str(row, 1);
r.lang = extract_str(row, 2); r.domain = extract_str(row, 3);
r.description = extract_str(row, 4); r.framework = extract_str(row, 5);
r.repo_url = extract_str(row, 6); r.dir_path = extract_str(row, 7);
out.apps.push_back(std::move(r));
}
}
// --- Analysis ---
out.analyses.clear();
j = api_query(cli, "SELECT id, name, domain, description FROM analysis ORDER BY name");
j = api_query(cli, "SELECT id, name, lang, domain, description FROM analysis ORDER BY name");
if (!j.is_null() && j.contains("rows")) {
for (auto& row : j["rows"]) {
AnalysisRow r;
r.id = extract_str(row, 0); r.name = extract_str(row, 1);
r.domain = extract_str(row, 2); r.description = extract_str(row, 3);
r.lang = extract_str(row, 2); r.domain = extract_str(row, 3);
r.description = extract_str(row, 4);
out.analyses.push_back(std::move(r));
}
}
@@ -172,5 +174,241 @@ bool load_registry_data_http(const std::string& api_url, RegistryData& out) {
}
out.prepare_chart_data();
// Best-effort: projects (no fatal si falla)
load_projects_http(api_url, out);
return true;
}
// ---------------------------------------------------------------------------
// Projects endpoints
// ---------------------------------------------------------------------------
bool load_projects_http(const std::string& api_url, RegistryData& out) {
std::string host; int port;
if (!parse_url(api_url, host, port)) return false;
HttpClient cli(host, port);
auto res = cli.get("/api/projects");
if (!res.ok()) {
fprintf(stderr, "[http] GET /api/projects failed: %d\n", res.status);
return false;
}
auto j = json::parse(res.body, nullptr, false);
if (j.is_null()) return false;
out.projects.clear();
if (j.contains("projects") && j["projects"].is_array()) {
for (auto& p : j["projects"]) {
ProjectRow r;
if (p.contains("id") && p["id"].is_string()) r.id = p["id"];
if (p.contains("name") && p["name"].is_string()) r.name = p["name"];
if (p.contains("description") && p["description"].is_string()) r.description = p["description"];
if (p.contains("apps_count") && p["apps_count"].is_number()) r.apps_count = p["apps_count"].get<int>();
if (p.contains("analyses_count") && p["analyses_count"].is_number()) r.analyses_count = p["analyses_count"].get<int>();
if (p.contains("vaults_count") && p["vaults_count"].is_number()) r.vaults_count = p["vaults_count"].get<int>();
out.projects.push_back(std::move(r));
}
}
if (j.contains("orphans") && j["orphans"].is_object()) {
auto& o = j["orphans"];
if (o.contains("apps") && o["apps"].is_number()) out.orphan_apps = o["apps"].get<int>();
if (o.contains("analyses") && o["analyses"].is_number()) out.orphan_analyses = o["analyses"].get<int>();
if (o.contains("vaults") && o["vaults"].is_number()) out.orphan_vaults = o["vaults"].get<int>();
}
return true;
}
bool load_project_detail_http(const std::string& api_url,
const std::string& id,
ProjectDetail& out) {
std::string host; int port;
if (!parse_url(api_url, host, port)) return false;
HttpClient cli(host, port);
auto res = cli.get("/api/projects/" + id);
if (!res.ok()) {
fprintf(stderr, "[http] GET /api/projects/%s failed: %d\n", id.c_str(), res.status);
return false;
}
auto j = json::parse(res.body, nullptr, false);
if (j.is_null()) return false;
out = ProjectDetail{};
out.id = id;
if (j.contains("project") && j["project"].is_object()) {
auto& p = j["project"];
if (p.contains("name") && p["name"].is_string()) out.name = p["name"];
if (p.contains("description") && p["description"].is_string()) out.description = p["description"];
}
auto read_rows = [](const json& section) -> std::pair<std::vector<std::string>, std::vector<std::vector<std::string>>> {
std::vector<std::string> cols;
std::vector<std::vector<std::string>> rows;
if (!section.is_object()) return {cols, rows};
if (section.contains("columns") && section["columns"].is_array())
for (auto& c : section["columns"])
cols.push_back(c.is_string() ? c.get<std::string>() : "");
if (section.contains("rows") && section["rows"].is_array()) {
for (auto& row : section["rows"]) {
std::vector<std::string> cells;
for (auto& v : row) {
if (v.is_string()) cells.push_back(v.get<std::string>());
else if (v.is_number_integer()) cells.push_back(std::to_string(v.get<long long>()));
else if (v.is_null()) cells.push_back("");
else cells.push_back(v.dump());
}
rows.push_back(std::move(cells));
}
}
return {cols, rows};
};
// apps: [id, name, lang, domain, framework, description, dir_path]
if (j.contains("apps")) {
auto [cols, rows] = read_rows(j["apps"]);
for (auto& r : rows) {
AppRow a;
if (r.size() > 0) a.id = r[0];
if (r.size() > 1) a.name = r[1];
if (r.size() > 2) a.lang = r[2];
if (r.size() > 3) a.domain = r[3];
if (r.size() > 4) a.framework = r[4];
if (r.size() > 5) a.description = r[5];
out.apps.push_back(std::move(a));
}
}
// analyses: [id, name, lang, domain, description, dir_path]
if (j.contains("analyses")) {
auto [cols, rows] = read_rows(j["analyses"]);
for (auto& r : rows) {
AnalysisRow a;
if (r.size() > 0) a.id = r[0];
if (r.size() > 1) a.name = r[1];
if (r.size() > 2) a.lang = r[2];
if (r.size() > 3) a.domain = r[3];
if (r.size() > 4) a.description = r[4];
out.analyses.push_back(std::move(a));
}
}
// vaults: [id, name, path, symlink, description, tags]
if (j.contains("vaults")) {
auto [cols, rows] = read_rows(j["vaults"]);
for (auto& r : rows) {
VaultRow v;
if (r.size() > 0) v.id = r[0];
if (r.size() > 1) v.name = r[1];
if (r.size() > 2) v.path = r[2];
if (r.size() > 3) v.symlink = (r[3] == "1");
if (r.size() > 4) v.description = r[4];
out.vaults.push_back(std::move(v));
}
}
return true;
}
// ---------------------------------------------------------------------------
// Mutation endpoints
// ---------------------------------------------------------------------------
static bool post_json(const std::string& api_url, const std::string& path,
const json& body, std::string& out_body) {
std::string host; int port;
if (!parse_url(api_url, host, port)) {
out_body = "invalid API URL: " + api_url;
return false;
}
HttpClient cli(host, port);
auto res = cli.post(path, body.dump(), "application/json");
// Mensaje util para el toast: si OK, intenta sacar "output" del JSON.
// Si error, incluye status + error del body si existe.
if (res.ok()) {
auto j = json::parse(res.body, nullptr, false);
if (!j.is_null()) {
if (j.contains("output") && j["output"].is_string())
out_body = j["output"].get<std::string>();
else if (j.contains("ok") && j["ok"].is_boolean())
out_body = j["ok"].get<bool>() ? "OK" : "failed";
else
out_body = res.body;
} else {
out_body = res.body.empty() ? "OK" : res.body;
}
return true;
}
// Error path con ASCII (la fuente puede no tener em dash). Para status=0
// el http_client ya ha escrito un diagnostico de connect() en res.body.
if (res.status == 0) {
out_body = res.body.empty()
? "connection failed (is sqlite_api running?)"
: res.body;
return false;
}
auto j = json::parse(res.body, nullptr, false);
std::string detail;
if (!j.is_null() && j.contains("error") && j["error"].is_string())
detail = j["error"].get<std::string>();
else if (!res.body.empty())
detail = res.body;
char buf[64];
std::snprintf(buf, sizeof(buf), "HTTP %d: ", res.status);
out_body = std::string(buf) + detail;
return false;
}
bool http_post_reindex(const std::string& api_url, std::string& out_body) {
return post_json(api_url, "/api/reindex", json::object(), out_body);
}
bool http_post_add_app(const std::string& api_url,
const std::string& name, const std::string& lang,
const std::string& domain, const std::string& project,
const std::string& description,
std::string& out_body) {
json b;
b["name"] = name;
b["lang"] = lang;
b["domain"] = domain;
b["project"] = project;
b["description"] = description;
return post_json(api_url, "/api/add/app", b, out_body);
}
bool http_post_add_analysis(const std::string& api_url,
const std::string& name, const std::string& project,
const std::string& packages_csv,
const std::string& description,
std::string& out_body) {
json b;
b["name"] = name;
b["project"] = project;
b["description"] = description;
// Packages como array. Split CSV.
json pkgs = json::array();
std::string cur;
for (char c : packages_csv) {
if (c == ',' || c == ' ') {
if (!cur.empty()) { pkgs.push_back(cur); cur.clear(); }
} else cur.push_back(c);
}
if (!cur.empty()) pkgs.push_back(cur);
b["packages"] = pkgs;
return post_json(api_url, "/api/add/analysis", b, out_body);
}
bool http_post_add_vault(const std::string& api_url,
const std::string& name, const std::string& project,
const std::string& path, const std::string& description,
std::string& out_body) {
json b;
b["name"] = name;
b["project"] = project;
b["path"] = path;
b["description"] = description;
return post_json(api_url, "/api/add/vault", b, out_body);
}