feat: initial scaffold of kanban_cpp v2 (issue 0130)

Frontend C++ ImGui (main.cpp + 4 paneles) + backend Go (HTTP + SQLite + fsnotify + SSE).
Reusa parse/scan/watch funcs del registry (issue 0130a).
This commit is contained in:
agent
2026-05-22 22:19:47 +02:00
commit 255e8dcf71
21 changed files with 2178 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
#include "panels.h"
#include "data.h"
#include <imgui.h>
#include <mutex>
#include <string>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
static std::string join(const std::vector<std::string>& xs, const char* sep) {
std::string out;
for (size_t i = 0; i < xs.size(); ++i) {
if (i) out += sep;
out += xs[i];
}
return out;
}
static int find_idx(const std::vector<std::string>& xs, const std::string& v) {
for (size_t i = 0; i < xs.size(); ++i) if (xs[i] == v) return (int)i;
return -1;
}
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 == ',') {
// trim
while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin());
while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back();
if (!cur.empty()) out.push_back(cur);
cur.clear();
} else {
cur.push_back(c);
}
}
while (!cur.empty() && (cur.front() == ' ' || cur.front() == '\t')) cur.erase(cur.begin());
while (!cur.empty() && (cur.back() == ' ' || cur.back() == '\t')) cur.pop_back();
if (!cur.empty()) out.push_back(cur);
return out;
}
static std::string js_arr(const std::vector<std::string>& xs) {
std::string out = "[";
for (size_t i = 0; i < xs.size(); ++i) {
if (i) out += ",";
out += "\"";
for (char c : xs[i]) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
out += "\"";
}
out += "]";
return out;
}
static std::string js_str(const std::string& s) {
std::string out = "\"";
for (char c : s) {
if (c == '"' || c == '\\') out += '\\';
out += c;
}
out += "\"";
return out;
}
void draw_detail() {
if (!ImGui::Begin(TI_INFO_CIRCLE " Detail")) {
ImGui::End();
return;
}
Issue iss;
Meta meta;
std::string sel_id;
{
std::lock_guard<std::mutex> g(state().mu);
sel_id = state().selected_issue_id;
iss = state().selected_issue_detail;
meta = state().meta;
}
if (sel_id.empty()) {
ImGui::TextDisabled("Click a card on the Board to load detail.");
ImGui::End();
return;
}
ImGui::Text("%s — %s", iss.id.c_str(), iss.title.c_str());
ImGui::SameLine();
if (ImGui::SmallButton(TI_REFRESH "##reload")) {
refresh_issue_detail(sel_id);
}
ImGui::Separator();
// Status combo
int s_idx = find_idx(meta.statuses, iss.status);
{
std::vector<const char*> items;
for (const auto& s : meta.statuses) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Status", &s_idx, items.data(), (int)items.size())) {
if (s_idx >= 0 && s_idx < (int)meta.statuses.size()) {
patch_issue_status(sel_id, meta.statuses[s_idx]);
refresh_issue_detail(sel_id);
}
}
}
// Priority combo
int p_idx = find_idx(meta.priorities, iss.priority);
{
std::vector<const char*> items;
for (const auto& s : meta.priorities) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Priority", &p_idx, items.data(), (int)items.size())) {
if (p_idx >= 0 && p_idx < (int)meta.priorities.size()) {
std::string body = "{\"priority\":" + js_str(meta.priorities[p_idx]) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
}
}
// Scope combo
int sc_idx = find_idx(meta.scopes, iss.scope);
{
std::vector<const char*> items;
for (const auto& s : meta.scopes) items.push_back(s.c_str());
if (!items.empty() && ImGui::Combo("Scope", &sc_idx, items.data(), (int)items.size())) {
if (sc_idx >= 0 && sc_idx < (int)meta.scopes.size()) {
std::string body = "{\"scope\":" + js_str(meta.scopes[sc_idx]) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
}
}
ImGui::Separator();
// Tags / domain / depends / blocks via CSV editors
auto edit_csv = [&](const char* label, std::vector<std::string>& field, const char* json_key) {
static char buf[1024];
std::string cur = join(field, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
if (ImGui::InputText(label, buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"";
body += json_key;
body += "\":";
body += js_arr(parts);
body += "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
};
// For each list, we render in its own scope to keep buf separate.
// ImGui uses widget id to disambiguate; we add ## suffix.
{
static char buf[1024];
std::string cur = join(iss.tags, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("tags");
if (ImGui::InputText("Tags (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"tags\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.domain, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("domain");
if (ImGui::InputText("Domain (CSV, Enter to save)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"domain\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.depends, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("depends");
if (ImGui::InputText("Depends (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"depends\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
{
static char buf[1024];
std::string cur = join(iss.blocks, ", ");
std::snprintf(buf, sizeof(buf), "%s", cur.c_str());
ImGui::PushID("blocks");
if (ImGui::InputText("Blocks (CSV, Enter)", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) {
auto parts = split_csv(buf);
std::string body = "{\"blocks\":" + js_arr(parts) + "}";
patch_issue_fields(sel_id, body);
refresh_issue_detail(sel_id);
}
ImGui::PopID();
}
ImGui::Separator();
ImGui::TextDisabled("%s", iss.file_path.c_str());
if (ImGui::CollapsingHeader("Body (read-only)")) {
ImGui::BeginChild("body_ro", ImVec2(0, 320), true);
ImGui::TextWrapped("%s", iss.body.c_str());
ImGui::EndChild();
}
(void)edit_csv; // silence unused (we inline the variants instead)
ImGui::End();
}
} // namespace kanban