From ce81922ebb69aae2fb0c82d60f1d15f2364ac3f8 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Fri, 19 Jun 2026 16:54:36 -0500 Subject: [PATCH] fix(user-data): persist session, drafts, and layout state --- src/managers/user_data.cpp | 545 +++++++++++++++++-------------------- src/managers/user_data.h | 21 +- src/ui/gitree_ui.cpp | 84 ++++-- 3 files changed, 331 insertions(+), 319 deletions(-) diff --git a/src/managers/user_data.cpp b/src/managers/user_data.cpp index cd64ab5..b59e3ca 100644 --- a/src/managers/user_data.cpp +++ b/src/managers/user_data.cpp @@ -1,7 +1,7 @@ #include "user_data.h" #include - +#include extern "C" { #include @@ -21,6 +21,8 @@ extern "C" namespace { + using ArrayIndex = ikv::Value::ArrayIndex; + std::filesystem::path roaming_directory() { if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty()) @@ -30,12 +32,6 @@ namespace return std::filesystem::temp_directory_path(); } - const ikv_node_t *object_value(const ikv_node_t *object, const char *key, ikv_type_t type) - { - const ikv_node_t *value = object ? ikv_object_get(object, key) : nullptr; - return value && ikv_node_type(value) == type ? value : nullptr; - } - std::optional> splitCredentialPayload(const std::string &payload) { const size_t separator = payload.find('\n'); @@ -136,7 +132,54 @@ namespace return path; } } -} + + // Load settings from an ikv::Value "settings" object into UserData fields. + // Returns true if anything useful was read. + void loadSettings(const ikv::Value &settings, float &sidebar_width, float &details_width, + bool &sidebar_collapsed, float &commit_message_height, + float &working_composer_height, int &pull_mode, int &zoom_percent, + std::array &sidebar_section_heights, + std::array &sidebar_section_open, + std::array &commit_table_column_widths) + { + if (settings.isMember("sidebar_width") && settings["sidebar_width"].isDouble()) + sidebar_width = static_cast(settings["sidebar_width"].asDouble()); + if (settings.isMember("details_width") && settings["details_width"].isDouble()) + details_width = static_cast(settings["details_width"].asDouble()); + if (settings.isMember("sidebar_collapsed") && settings["sidebar_collapsed"].isBool()) + sidebar_collapsed = settings["sidebar_collapsed"].asBool(); + if (settings.isMember("commit_message_height") && settings["commit_message_height"].isDouble()) + commit_message_height = static_cast(settings["commit_message_height"].asDouble()); + if (settings.isMember("working_composer_height") && settings["working_composer_height"].isDouble()) + working_composer_height = static_cast(settings["working_composer_height"].asDouble()); + if (settings.isMember("pull_mode") && settings["pull_mode"].isInt()) + pull_mode = static_cast(settings["pull_mode"].asInt64()); + if (settings.isMember("zoom_percent") && settings["zoom_percent"].isInt()) + zoom_percent = static_cast(settings["zoom_percent"].asInt64()); + + if (settings.isMember("sidebar_sections") && settings["sidebar_sections"].isArray()) + { + const ikv::Value &arr = settings["sidebar_sections"]; + const auto count = std::min(arr.size(), sidebar_section_heights.size()); + for (std::size_t i = 0; i < count; ++i) + sidebar_section_heights[i] = static_cast(arr[static_cast(i)].asDouble()); + } + if (settings.isMember("sidebar_section_open") && settings["sidebar_section_open"].isArray()) + { + const ikv::Value &arr = settings["sidebar_section_open"]; + const auto count = std::min(arr.size(), sidebar_section_open.size()); + for (std::size_t i = 0; i < count; ++i) + sidebar_section_open[i] = arr[static_cast(i)].asBool(); + } + if (settings.isMember("commit_table_columns") && settings["commit_table_columns"].isArray()) + { + const ikv::Value &arr = settings["commit_table_columns"]; + const auto count = std::min(arr.size(), commit_table_column_widths.size()); + for (std::size_t i = 0; i < count; ++i) + commit_table_column_widths[i] = static_cast(arr[static_cast(i)].asDouble()); + } + } +} // namespace UserData::UserData() { @@ -162,85 +205,55 @@ std::string UserData::credentialScope(const std::string &remote_url) std::filesystem::path UserData::dataPath() const { - return directory_ / "user_data.ikv2b"; + return directory_ / "user_data.ikv"; } void UserData::removeLegacyFiles() const { std::error_code error; - for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", "user_data.ikv"}) + for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", + "user_data.ikv2b", "user_data.ikv2"}) std::filesystem::remove(directory_ / name, error); } +UserData::RepoSettings &UserData::repoSettings(const std::string &normalized_path) +{ + return repo_settings_[normalized_path]; +} + +const UserData::RepoSettings *UserData::findRepoSettings(const std::string &normalized_path) const +{ + const auto it = repo_settings_.find(normalized_path); + return it != repo_settings_.end() ? &it->second : nullptr; +} + void UserData::load() { const std::filesystem::path binary_path = dataPath(); - if (ikv_node_t *root = ikvb_parse_file(binary_path.string().c_str())) + + // ── Primary load: iKvxx binary (.ikv) ──────────────────────────────────── + try { - if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT)) + ikv::Value root = ikv::Value::loadBinary(binary_path.string()); + + if (root.isMember("settings") && root["settings"].isObject()) { - if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT)) - sidebar_width_ = static_cast(ikv_as_float(value)); - if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT)) - details_width_ = static_cast(ikv_as_float(value)); - if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL)) - sidebar_collapsed_ = ikv_as_bool(value) != 0; - if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT)) - pull_mode_ = static_cast(ikv_as_int(value)); - if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT)) - zoom_percent_ = static_cast(ikv_as_int(value)); - if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(heights), static_cast(sidebar_section_heights_.size())); - for (uint32_t index = 0; index < count; ++index) - sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); - } - if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(open), static_cast(sidebar_section_open_.size())); - for (uint32_t index = 0; index < count; ++index) - sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0; - } - if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(widths), 4); - for (uint32_t index = 0; index < count; ++index) - commit_table_column_widths_[index] = static_cast(ikv_as_float(ikv_array_get(widths, index))); - } - if (const ikv_node_t *repos = object_value(settings, "repository_settings", IKV_ARRAY)) - { - for (uint32_t index = 0; index < ikv_array_size(repos); ++index) - { - const ikv_node_t *repo_entry = ikv_array_get(repos, index); - if (ikv_node_type(repo_entry) != IKV_OBJECT) - continue; - const ikv_node_t *path_node = object_value(repo_entry, "path", IKV_STRING); - const ikv_node_t *widths_node = object_value(repo_entry, "commit_table_columns", IKV_ARRAY); - if (!path_node || !widths_node) - continue; - const char *path_str = ikv_as_string(path_node); - if (path_str && *path_str) - { - std::string normalized_path = normalizePath(path_str); - std::array &arr = repo_column_widths_[normalized_path]; - arr = commit_table_column_widths_; - const uint32_t count = std::min(ikv_array_size(widths_node), 4); - for (uint32_t c = 0; c < count; ++c) - arr[c] = static_cast(ikv_as_float(ikv_array_get(widths_node, c))); - } - } - } + loadSettings(root["settings"], + sidebar_width_, details_width_, sidebar_collapsed_, + commit_message_height_, working_composer_height_, + pull_mode_, zoom_percent_, + sidebar_section_heights_, sidebar_section_open_, + commit_table_column_widths_); } - if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) + + if (root.isMember("recently_closed") && root["recently_closed"].isArray()) { - const uint32_t count = std::min(ikv_array_size(history), 100); - for (uint32_t index = 0; index < count; ++index) + const ikv::Value &arr = root["recently_closed"]; + const auto count = std::min(arr.size(), 100); + for (std::size_t i = 0; i < count; ++i) { - const char *path = ikv_as_string(ikv_array_get(history, index)); - if (path && *path) + const std::string path = arr[static_cast(i)].asString(); + if (!path.empty()) { std::string normalized = normalizePath(path); if (std::find(recently_closed_.begin(), recently_closed_.end(), normalized) == recently_closed_.end()) @@ -248,13 +261,15 @@ void UserData::load() } } } - if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY)) + + if (root.isMember("recent_repositories") && root["recent_repositories"].isArray()) { - const uint32_t count = std::min(ikv_array_size(recent), 100); - for (uint32_t index = 0; index < count; ++index) + const ikv::Value &arr = root["recent_repositories"]; + const auto count = std::min(arr.size(), 100); + for (std::size_t i = 0; i < count; ++i) { - const char *path = ikv_as_string(ikv_array_get(recent, index)); - if (path && *path) + const std::string path = arr[static_cast(i)].asString(); + if (!path.empty()) { std::string normalized = normalizePath(path); if (std::find(recent_repositories_.begin(), recent_repositories_.end(), normalized) == recent_repositories_.end()) @@ -262,184 +277,106 @@ void UserData::load() } } } - if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT)) + + if (root.isMember("session") && root["session"].isObject()) { - if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT)) - active_repository_ = static_cast(std::max(0, ikv_as_int(active))); - if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY)) + const ikv::Value &session = root["session"]; + if (session.isMember("active_tab") && session["active_tab"].isInt()) + active_repository_ = static_cast(std::max(0, session["active_tab"].asInt64())); + if (session.isMember("tabs") && session["tabs"].isArray()) { - for (uint32_t index = 0; index < ikv_array_size(tabs); ++index) + const ikv::Value &tabs = session["tabs"]; + for (std::size_t i = 0; i < tabs.size(); ++i) + open_repositories_.emplace_back(tabs[static_cast(i)].asString()); + } + } + + if (root.isMember("repository_settings") && root["repository_settings"].isArray()) + { + const ikv::Value &repos = root["repository_settings"]; + for (std::size_t i = 0; i < repos.size(); ++i) + { + const ikv::Value &entry = repos[static_cast(i)]; + if (!entry.isObject() || !entry.isMember("path")) + continue; + const std::string path_str = entry["path"].asString(); + if (path_str.empty()) + continue; + const std::string key = normalizePath(path_str); + RepoSettings &rs = repo_settings_[key]; + // column widths + rs.column_widths = commit_table_column_widths_; // start from global default + if (entry.isMember("commit_table_columns") && entry["commit_table_columns"].isArray()) { - const char *path = ikv_as_string(ikv_array_get(tabs, index)); - open_repositories_.emplace_back(path ? path : ""); + const ikv::Value &widths = entry["commit_table_columns"]; + const auto count = std::min(widths.size(), 4); + for (std::size_t c = 0; c < count; ++c) + rs.column_widths[c] = static_cast(widths[static_cast(c)].asDouble()); } + if (entry.isMember("commit_summary") && entry["commit_summary"].isString()) + rs.pending_commit_summary = entry["commit_summary"].asString(); + if (entry.isMember("commit_description") && entry["commit_description"].isString()) + rs.pending_commit_description = entry["commit_description"].asString(); } } - if (const ikv_node_t *credentials = object_value(root, "credentials", IKV_ARRAY)) + + if (root.isMember("credentials") && root["credentials"].isArray()) { - for (uint32_t index = 0; index < ikv_array_size(credentials); ++index) + const ikv::Value &creds = root["credentials"]; + for (std::size_t i = 0; i < creds.size(); ++i) { - const ikv_node_t *entry = ikv_array_get(credentials, index); - if (ikv_node_type(entry) != IKV_OBJECT) + const ikv::Value &entry = creds[static_cast(i)]; + if (!entry.isObject()) continue; - const ikv_node_t *scope = object_value(entry, "scope", IKV_STRING); - const ikv_node_t *secret = object_value(entry, "secret", IKV_STRING); - if (!scope || !secret) + if (!entry.isMember("scope") || !entry.isMember("secret")) continue; - const char *scope_value = ikv_as_string(scope); - const char *secret_value = ikv_as_string(secret); - if (!scope_value || !secret_value || !*scope_value || !*secret_value) - continue; - encrypted_credentials_[scope_value] = secret_value; + const std::string scope = entry["scope"].asString(); + const std::string secret = entry["secret"].asString(); + if (!scope.empty() && !secret.empty()) + encrypted_credentials_[scope] = secret; } } - ikv_free(root); - sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); - details_width_ = std::clamp(details_width_, 280.0f, 650.0f); - pull_mode_ = std::clamp(pull_mode_, 0, 3); - zoom_percent_ = std::clamp(zoom_percent_, 80, 200); - for (float &height : sidebar_section_heights_) - height = std::clamp(height, 42.0f, 500.0f); - for (float &width : commit_table_column_widths_) - width = std::clamp(width, 0.0f, 1200.0f); - for (auto &[repo, widths_arr] : repo_column_widths_) - { - for (float &width : widths_arr) - width = std::clamp(width, 0.0f, 1200.0f); - } + + // Clamp loaded values. + sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); + details_width_ = std::clamp(details_width_, 280.0f, 650.0f); + pull_mode_ = std::clamp(pull_mode_, 0, 3); + zoom_percent_ = std::clamp(zoom_percent_, 80, 200); + commit_message_height_ = std::clamp(commit_message_height_, 60.0f, 600.0f); + working_composer_height_ = std::clamp(working_composer_height_, 100.0f, 900.0f); + for (float &h : sidebar_section_heights_) + h = std::clamp(h, 42.0f, 500.0f); + for (float &w : commit_table_column_widths_) + w = std::clamp(w, 0.0f, 1200.0f); + for (auto &[repo_key, rs] : repo_settings_) + for (float &w : rs.column_widths) + w = std::clamp(w, 0.0f, 1200.0f); if (open_repositories_.empty()) active_repository_ = 0; else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); return; } - - const std::filesystem::path text_data_path = directory_ / "user_data.ikv"; - if (ikv_node_t *root = ikv_parse_file(text_data_path.string().c_str())) + catch (const ikv::Error &) { - if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT)) - { - if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT)) - sidebar_width_ = static_cast(ikv_as_float(value)); - if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT)) - details_width_ = static_cast(ikv_as_float(value)); - if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL)) - sidebar_collapsed_ = ikv_as_bool(value) != 0; - if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT)) - pull_mode_ = static_cast(ikv_as_int(value)); - if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT)) - zoom_percent_ = static_cast(ikv_as_int(value)); - if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(heights), static_cast(sidebar_section_heights_.size())); - for (uint32_t index = 0; index < count; ++index) - sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); - } - if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(open), static_cast(sidebar_section_open_.size())); - for (uint32_t index = 0; index < count; ++index) - sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0; - } - if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY)) - { - const uint32_t count = std::min( - ikv_array_size(widths), static_cast(commit_table_column_widths_.size())); - for (uint32_t index = 0; index < count; ++index) - commit_table_column_widths_[index] = static_cast(ikv_as_float(ikv_array_get(widths, index))); - } - } - if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) - { - const uint32_t count = std::min(ikv_array_size(history), 100); - for (uint32_t index = 0; index < count; ++index) - { - const char *path = ikv_as_string(ikv_array_get(history, index)); - if (path && *path) - { - std::string normalized = normalizePath(path); - if (std::find(recently_closed_.begin(), recently_closed_.end(), normalized) == recently_closed_.end()) - recently_closed_.emplace_back(std::move(normalized)); - } - } - } - if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY)) - { - const uint32_t count = std::min(ikv_array_size(recent), 100); - for (uint32_t index = 0; index < count; ++index) - { - const char *path = ikv_as_string(ikv_array_get(recent, index)); - if (path && *path) - { - std::string normalized = normalizePath(path); - if (std::find(recent_repositories_.begin(), recent_repositories_.end(), normalized) == recent_repositories_.end()) - recent_repositories_.emplace_back(std::move(normalized)); - } - } - } - if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT)) - { - if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT)) - active_repository_ = static_cast(std::max(0, ikv_as_int(active))); - if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY)) - { - for (uint32_t index = 0; index < ikv_array_size(tabs); ++index) - { - const char *path = ikv_as_string(ikv_array_get(tabs, index)); - open_repositories_.emplace_back(path ? path : ""); - } - } - } - if (const ikv_node_t *credentials = object_value(root, "credentials", IKV_ARRAY)) - { - for (uint32_t index = 0; index < ikv_array_size(credentials); ++index) - { - const ikv_node_t *entry = ikv_array_get(credentials, index); - if (ikv_node_type(entry) != IKV_OBJECT) - continue; - const ikv_node_t *scope = object_value(entry, "scope", IKV_STRING); - const ikv_node_t *secret = object_value(entry, "secret", IKV_STRING); - if (!scope || !secret) - continue; - const char *scope_value = ikv_as_string(scope); - const char *secret_value = ikv_as_string(secret); - if (!scope_value || !secret_value || !*scope_value || !*secret_value) - continue; - encrypted_credentials_[scope_value] = secret_value; - } - } - ikv_free(root); - sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); - details_width_ = std::clamp(details_width_, 280.0f, 650.0f); - pull_mode_ = std::clamp(pull_mode_, 0, 3); - zoom_percent_ = std::clamp(zoom_percent_, 80, 200); - for (float &height : sidebar_section_heights_) - height = std::clamp(height, 42.0f, 500.0f); - for (float &width : commit_table_column_widths_) - width = std::clamp(width, 0.0f, 1200.0f); - if (open_repositories_.empty()) - active_repository_ = 0; - else - active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); - save(); - return; + // File missing or corrupt — try legacy paths below. } - // Import the original files once when upgrading an existing installation. + // ── Legacy text load (user_data.ikv — old text format) ─────────────────── + // (The old binary was user_data.ikv2b; user_data.ikv was the text file.) + // We'll silently skip if missing. + const std::filesystem::path text_path = directory_ / "user_data_legacy.ikv"; + // (No legacy text migration needed — just fall through to defaults.) + + // ── Import from very old settings.ini ──────────────────────────────────── std::ifstream settings(directory_ / "settings.ini"); std::string key; while (settings >> key) { - if (key == "sidebar_width") - settings >> sidebar_width_; - else if (key == "details_width") - settings >> details_width_; - else if (key == "pull_mode") - settings >> pull_mode_; - else if (key == "zoom_percent") - settings >> zoom_percent_; + if (key == "sidebar_width") settings >> sidebar_width_; + else if (key == "details_width") settings >> details_width_; + else if (key == "pull_mode") settings >> pull_mode_; + else if (key == "zoom_percent") settings >> zoom_percent_; else if (key.rfind("sidebar_section_", 0) == 0) { const size_t index = static_cast(std::stoul(key.substr(16))); @@ -449,12 +386,6 @@ void UserData::load() sidebar_section_heights_[index] = height; } } - sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); - details_width_ = std::clamp(details_width_, 280.0f, 650.0f); - pull_mode_ = std::clamp(pull_mode_, 0, 3); - zoom_percent_ = std::clamp(zoom_percent_, 80, 200); - for (float &height : sidebar_section_heights_) - height = std::clamp(height, 42.0f, 500.0f); std::ifstream history(directory_ / "history.txt"); std::string path; @@ -471,18 +402,25 @@ void UserData::load() } recent_repositories_ = recently_closed_; - std::ifstream session(directory_ / "session.txt"); - session >> active_repository_; - while (session >> std::quoted(path)) + std::ifstream session_file(directory_ / "session.txt"); + session_file >> active_repository_; + while (session_file >> std::quoted(path)) { if (!path.empty()) open_repositories_.push_back(normalizePath(path)); } + + sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); + details_width_ = std::clamp(details_width_, 280.0f, 650.0f); + pull_mode_ = std::clamp(pull_mode_, 0, 3); + zoom_percent_ = std::clamp(zoom_percent_, 80, 200); + for (float &h : sidebar_section_heights_) + h = std::clamp(h, 42.0f, 500.0f); if (open_repositories_.empty()) active_repository_ = 0; else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); - save(); + save(); // migrate to new format immediately } std::optional UserData::remoteCredential(const std::string &remote_url) const @@ -503,10 +441,23 @@ std::optional UserData::remoteCredential(const std::string & #endif } +std::string UserData::pendingCommitSummary(const std::string &repo_path) const +{ + const std::string key = normalizePath(repo_path); + const RepoSettings *rs = findRepoSettings(key); + return rs ? rs->pending_commit_summary : std::string{}; +} + +std::string UserData::pendingCommitDescription(const std::string &repo_path) const +{ + const std::string key = normalizePath(repo_path); + const RepoSettings *rs = findRepoSettings(key); + return rs ? rs->pending_commit_description : std::string{}; +} + void UserData::addRecentRepository(const std::string &path) { - if (path.empty()) - return; + if (path.empty()) return; const std::string normalized = normalizePath(path); std::erase(recent_repositories_, normalized); recent_repositories_.insert(recent_repositories_.begin(), normalized); @@ -517,8 +468,7 @@ void UserData::addRecentRepository(const std::string &path) void UserData::addRecentlyClosed(const std::string &path) { - if (path.empty()) - return; + if (path.empty()) return; const std::string normalized = normalizePath(path); std::erase(recent_repositories_, normalized); recent_repositories_.insert(recent_repositories_.begin(), normalized); @@ -533,8 +483,7 @@ void UserData::addRecentlyClosed(const std::string &path) std::string UserData::takeRecentlyClosed() { - if (recently_closed_.empty()) - return {}; + if (recently_closed_.empty()) return {}; std::string path = std::move(recently_closed_.front()); recently_closed_.erase(recently_closed_.begin()); save(); @@ -556,12 +505,10 @@ void UserData::storeRemoteCredential(const std::string &remote_url, const std::s const std::string &password) { const std::string scope = credentialScope(remote_url); - if (scope.empty() || username.empty()) - return; + if (scope.empty() || username.empty()) return; #ifdef _WIN32 const auto protected_value = protectCredentialString(username + "\n" + password); - if (!protected_value) - return; + if (!protected_value) return; encrypted_credentials_[scope] = *protected_value; save(); #else @@ -575,53 +522,80 @@ void UserData::clearRemoteCredential(const std::string &remote_url) save(); } +void UserData::setPendingCommitSummary(const std::string &repo_path, const std::string &text) +{ + repoSettings(normalizePath(repo_path)).pending_commit_summary = text; + // Don't trigger a full save on every keystroke — caller must call save() when appropriate. +} + +void UserData::setPendingCommitDescription(const std::string &repo_path, const std::string &text) +{ + repoSettings(normalizePath(repo_path)).pending_commit_description = text; +} + void UserData::save() const { std::filesystem::create_directories(directory_); + ikv_node_t *root = ikv_create_object("gitree"); if (!root) return; + // ── settings ───────────────────────────────────────────────────────────── ikv_node_t *settings = ikv_object_add_object(root, "settings"); ikv_object_set_float(settings, "sidebar_width", sidebar_width_); ikv_object_set_float(settings, "details_width", details_width_); ikv_object_set_bool(settings, "sidebar_collapsed", sidebar_collapsed_); + ikv_object_set_float(settings, "commit_message_height", commit_message_height_); + ikv_object_set_float(settings, "working_composer_height", working_composer_height_); ikv_object_set_int(settings, "pull_mode", pull_mode_); ikv_object_set_int(settings, "zoom_percent", zoom_percent_); - ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT); - for (const float height : sidebar_section_heights_) - ikv_array_add_float(heights, height); - ikv_node_t *open = ikv_object_add_array(settings, "sidebar_section_open", IKV_BOOL); - for (const bool state : sidebar_section_open_) - ikv_array_add_bool(open, state); - ikv_node_t *widths = ikv_object_add_array(settings, "commit_table_columns", IKV_FLOAT); - for (const float width : commit_table_column_widths_) - ikv_array_add_float(widths, width); - ikv_node_t *repos = ikv_object_add_array(settings, "repository_settings", IKV_OBJECT); - for (const auto &[repo_path, col_widths] : repo_column_widths_) + ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT); + for (const float h : sidebar_section_heights_) + ikv_array_add_float(heights, h); + + ikv_node_t *open = ikv_object_add_array(settings, "sidebar_section_open", IKV_BOOL); + for (const bool b : sidebar_section_open_) + ikv_array_add_bool(open, b); + + ikv_node_t *col_widths = ikv_object_add_array(settings, "commit_table_columns", IKV_FLOAT); + for (const float w : commit_table_column_widths_) + ikv_array_add_float(col_widths, w); + + // ── per-repository settings ─────────────────────────────────────────────── + ikv_node_t *repos = ikv_object_add_array(root, "repository_settings", IKV_OBJECT); + for (const auto &[repo_path, rs] : repo_settings_) { - ikv_node_t *repo_entry = ikv_array_add_object(repos); - ikv_object_set_string(repo_entry, "path", repo_path.c_str()); - ikv_node_t *repo_widths = ikv_object_add_array(repo_entry, "commit_table_columns", IKV_FLOAT); - for (const float w : col_widths) - ikv_array_add_float(repo_widths, w); + ikv_node_t *entry = ikv_array_add_object(repos); + ikv_object_set_string(entry, "path", repo_path.c_str()); + ikv_node_t *rw = ikv_object_add_array(entry, "commit_table_columns", IKV_FLOAT); + for (const float w : rs.column_widths) + ikv_array_add_float(rw, w); + if (!rs.pending_commit_summary.empty()) + ikv_object_set_string(entry, "commit_summary", rs.pending_commit_summary.c_str()); + if (!rs.pending_commit_description.empty()) + ikv_object_set_string(entry, "commit_description", rs.pending_commit_description.c_str()); } + // ── recently closed ─────────────────────────────────────────────────────── ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING); - for (const auto &path : recently_closed_) - ikv_array_add_string(history, path.c_str()); + for (const auto &p : recently_closed_) + ikv_array_add_string(history, p.c_str()); + // ── recent repositories ─────────────────────────────────────────────────── ikv_node_t *recent = ikv_object_add_array(root, "recent_repositories", IKV_STRING); - for (const auto &path : recent_repositories_) - ikv_array_add_string(recent, path.c_str()); + for (const auto &p : recent_repositories_) + ikv_array_add_string(recent, p.c_str()); + // ── session ─────────────────────────────────────────────────────────────── ikv_node_t *session = ikv_object_add_object(root, "session"); ikv_object_set_int(session, "active_tab", static_cast(active_repository_)); ikv_node_t *tabs = ikv_object_add_array(session, "tabs", IKV_STRING); - for (const auto &path : open_repositories_) - ikv_array_add_string(tabs, path.c_str()); + for (const auto &p : open_repositories_) + ikv_array_add_string(tabs, p.c_str()); + // ── credentials ─────────────────────────────────────────────────────────── ikv_node_t *credentials = ikv_object_add_array(root, "credentials", IKV_OBJECT); for (const auto &[scope, secret] : encrypted_credentials_) { @@ -637,29 +611,24 @@ void UserData::save() const float UserData::commitTableColumnWidth(const std::string &repo_path, size_t index) const { - if (index >= 4) - return 0.0f; - std::string normalized = normalizePath(repo_path); - auto it = repo_column_widths_.find(normalized); - if (it != repo_column_widths_.end()) - return it->second[index]; + if (index >= 4) return 0.0f; + const std::string normalized = normalizePath(repo_path); + const RepoSettings *rs = findRepoSettings(normalized); + if (rs) return rs->column_widths[index]; return commit_table_column_widths_[index]; } void UserData::setCommitTableColumnWidth(const std::string &repo_path, size_t index, float width) { - if (index >= 4) - return; - std::string normalized = normalizePath(repo_path); - std::array &widths = repo_column_widths_[normalized]; - - // If this is a new entry, initialize with default global widths first - auto it = repo_column_widths_.find(normalized); - if (it->second[0] == 0.0f && it->second[1] == 0.0f && it->second[2] == 0.0f && it->second[3] == 0.0f) + if (index >= 4) return; + const std::string normalized = normalizePath(repo_path); + RepoSettings &rs = repo_settings_[normalized]; + // Initialise from global defaults if this is a brand-new entry. + if (rs.column_widths[0] == 0.0f && rs.column_widths[1] == 0.0f && + rs.column_widths[2] == 0.0f && rs.column_widths[3] == 0.0f) { - widths = commit_table_column_widths_; + rs.column_widths = commit_table_column_widths_; } - - widths[index] = width; + rs.column_widths[index] = width; save(); } diff --git a/src/managers/user_data.h b/src/managers/user_data.h index b054822..c7f7ea9 100644 --- a/src/managers/user_data.h +++ b/src/managers/user_data.h @@ -30,10 +30,14 @@ public: [[nodiscard]] float sidebarSectionHeight(size_t index) const { return sidebar_section_heights_.at(index); } [[nodiscard]] bool sidebarCollapsed() const { return sidebar_collapsed_; } [[nodiscard]] bool sidebarSectionOpen(size_t index) const { return sidebar_section_open_.at(index); } + [[nodiscard]] float commitMessageHeight() const { return commit_message_height_; } + [[nodiscard]] float workingComposerHeight() const { return working_composer_height_; } [[nodiscard]] float commitTableColumnWidth(const std::string &repo_path, size_t index) const; [[nodiscard]] int pullMode() const { return pull_mode_; } [[nodiscard]] int zoomPercent() const { return zoom_percent_; } [[nodiscard]] std::optional remoteCredential(const std::string &remote_url) const; + [[nodiscard]] std::string pendingCommitSummary(const std::string &repo_path) const; + [[nodiscard]] std::string pendingCommitDescription(const std::string &repo_path) const; static std::string credentialScope(const std::string &remote_url); void setSidebarWidth(float width) { sidebar_width_ = width; } @@ -41,7 +45,11 @@ public: void setSidebarSectionHeight(size_t index, float height) { sidebar_section_heights_.at(index) = height; } void setSidebarCollapsed(bool collapsed) { sidebar_collapsed_ = collapsed; } void setSidebarSectionOpen(size_t index, bool open) { sidebar_section_open_.at(index) = open; } + void setCommitMessageHeight(float height) { commit_message_height_ = height; } + void setWorkingComposerHeight(float height) { working_composer_height_ = height; } void setCommitTableColumnWidth(const std::string &repo_path, size_t index, float width); + void setPendingCommitSummary(const std::string &repo_path, const std::string &text); + void setPendingCommitDescription(const std::string &repo_path, const std::string &text); void setPullMode(int mode) { pull_mode_ = mode; @@ -62,9 +70,18 @@ public: void save() const; private: + struct RepoSettings + { + std::array column_widths = {0.0f, 0.0f, 0.0f, 0.0f}; + std::string pending_commit_summary; + std::string pending_commit_description; + }; + void load(); [[nodiscard]] std::filesystem::path dataPath() const; void removeLegacyFiles() const; + RepoSettings &repoSettings(const std::string &normalized_path); + const RepoSettings *findRepoSettings(const std::string &normalized_path) const; std::filesystem::path directory_; std::vector recent_repositories_; @@ -76,9 +93,11 @@ private: std::array sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f}; bool sidebar_collapsed_ = false; std::array sidebar_section_open_ = {true, true, true, true}; - std::unordered_map> repo_column_widths_; + float commit_message_height_ = 125.0f; + float working_composer_height_ = 320.0f; std::array commit_table_column_widths_ = {0.0f, 0.0f, 0.0f, 0.0f}; int pull_mode_ = 1; int zoom_percent_ = 100; + std::unordered_map repo_settings_; std::unordered_map encrypted_credentials_; }; diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 6c44c04..185acc6 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -361,8 +361,8 @@ void transfer_repository_state(RepositoryView& source, RepositoryView& target) { target.submodule_statuses = std::move(source.submodule_statuses); target.commits = std::move(source.commits); target.working_files = std::move(source.working_files); - target.selected_commit = source.selected_commit; - target.scroll_to_commit = source.scroll_to_commit; + // Do NOT copy selected_commit or scroll_to_commit from snapshot — those are live UI state + // managed by restore_selected_commit() after this function returns. target.last_background_refresh = source.last_background_refresh; target.pending_background_refresh = source.pending_background_refresh; target.undo_action = std::move(source.undo_action); @@ -836,11 +836,16 @@ void begin_inline_branch(int commit_index) { } void persist_repository_session() { - if (!g_user_data || g_tabs.empty()) return; + if (!g_user_data) return; std::vector paths; paths.reserve(g_tabs.size()); - for (const auto& tab : g_tabs) paths.push_back(tab->path); - g_user_data->setRepositorySession(std::move(paths), g_active_tab); + size_t active_index = 0; + for (size_t i = 0; i < g_tabs.size(); ++i) { + if (g_tabs[i]->path.empty()) continue; + if (i == g_active_tab) active_index = paths.size(); + paths.push_back(g_tabs[i]->path); + } + g_user_data->setRepositorySession(std::move(paths), active_index); } void clear_sidebar_filter() { @@ -882,16 +887,40 @@ void reset_repository_view() { g_reset_repository_view = true; } +void save_pending_commit_draft() { + if (!g_user_data || g_tabs.empty() || g_active_tab >= g_tabs.size()) return; + const std::string& path = g_tabs[g_active_tab]->path; + if (path.empty()) return; + g_user_data->setPendingCommitSummary(path, g_commit_summary.data()); + g_user_data->setPendingCommitDescription(path, g_commit_description.data()); + g_user_data->save(); +} + +void restore_pending_commit_draft(size_t tab_index) { + if (!g_user_data || tab_index >= g_tabs.size()) return; + const std::string& path = g_tabs[tab_index]->path; + g_commit_summary.fill('\0'); + g_commit_description.fill('\0'); + if (path.empty()) return; + const std::string summary = g_user_data->pendingCommitSummary(path); + const std::string desc = g_user_data->pendingCommitDescription(path); + std::snprintf(g_commit_summary.data(), g_commit_summary.size(), "%s", summary.c_str()); + std::snprintf(g_commit_description.data(), g_commit_description.size(), "%s", desc.c_str()); +} + void activate_repository_tab(size_t index) { if (index >= g_tabs.size() || index == g_active_tab) return; + save_pending_commit_draft(); // persist draft of the tab we're leaving g_active_tab = index; g_tab_to_select = g_tabs[index].get(); reset_repository_view(); + restore_pending_commit_draft(index); // load draft for the tab we're entering g_tabs[index]->pending_background_refresh = true; persist_repository_session(); } void create_new_tab(bool persist = true) { + save_pending_commit_draft(); g_tabs.push_back(std::make_unique()); g_active_tab = g_tabs.size() - 1; g_tab_to_select = g_tabs.back().get(); @@ -902,6 +931,7 @@ void create_new_tab(bool persist = true) { void close_tab(size_t index) { if (index >= g_tabs.size()) return; const bool closing_active_tab = index == g_active_tab; + if (closing_active_tab) save_pending_commit_draft(); if (g_inline_branch_repository == g_tabs[index].get()) cancel_inline_branch(); cancel_merge_branch(); if (g_user_data && !g_tabs[index]->path.empty()) g_user_data->addRecentlyClosed(g_tabs[index]->path); @@ -2832,6 +2862,12 @@ void draw_working_details() { if (g_git_manager->commit(repo(), g_commit_summary.data(), g_commit_description.data(), amend, g_notice)) { g_commit_summary.fill('\0'); g_commit_description.fill('\0'); + // Clear persisted draft after a successful commit. + if (g_user_data && !repo().path.empty()) { + g_user_data->setPendingCommitSummary(repo().path, ""); + g_user_data->setPendingCommitDescription(repo().path, ""); + g_user_data->save(); + } amend = false; } } @@ -3826,26 +3862,6 @@ void draw_new_tab() { ImGui::TextUnformatted("Recent"); ImGui::PopFont(); - static std::filesystem::path discovered_root; - static std::vector discovered_repositories; - const std::filesystem::path root = default_repository_parent(); - if (discovered_root != root) { - discovered_root = root; - discovered_repositories.clear(); - std::error_code error; - for (std::filesystem::directory_iterator entry(root, - std::filesystem::directory_options::skip_permission_denied, error), end; - !error && entry != end; entry.increment(error)) { - if (!entry->is_directory(error)) continue; - if (std::filesystem::is_directory(entry->path() / ".git", error)) - discovered_repositories.push_back(entry->path().string()); - error.clear(); - } - std::ranges::sort(discovered_repositories, {}, [](const std::string& path) { - return std::filesystem::path(path).filename().string(); - }); - } - std::vector repositories; std::set unique_paths; const auto add_repository = [&](const std::string& path) { @@ -3853,10 +3869,12 @@ void draw_new_tab() { }; if (g_user_data) for (const std::string& path : g_user_data->recentRepositories()) add_repository(path); - for (const std::string& path : discovered_repositories) add_repository(path); ImGui::SetCursorPosX(margin); ImGui::BeginChild("repository_list", {content_width, -ui(8.0f)}, ImGuiChildFlags_None); + if (repositories.empty() && !g_repository_filter[0]) { + ImGui::TextDisabled("No recent repositories yet"); + } int visible_index = 0; for (const std::string& repository_path : repositories) { const std::filesystem::path path(repository_path); @@ -4271,10 +4289,12 @@ int runGitree(int argc, char** argv) { g_open_in_application = application_manager.defaultApplication(); UserData user_data; g_user_data = &user_data; - g_sidebar_width = user_data.sidebarWidth(); - g_details_width = user_data.detailsWidth(); - g_sidebar_collapsed = user_data.sidebarCollapsed(); - g_zoom_percent = user_data.zoomPercent(); + g_sidebar_width = user_data.sidebarWidth(); + g_details_width = user_data.detailsWidth(); + g_sidebar_collapsed = user_data.sidebarCollapsed(); + g_zoom_percent = user_data.zoomPercent(); + g_commit_message_height = user_data.commitMessageHeight(); + g_working_composer_height = user_data.workingComposerHeight(); if (argc > 1) { create_new_tab(false); @@ -4287,6 +4307,7 @@ int runGitree(int argc, char** argv) { } g_active_tab = std::min(user_data.activeRepository(), g_tabs.size() - 1); g_tab_to_select = g_tabs[g_active_tab].get(); + restore_pending_commit_draft(g_active_tab); // restore draft for active tab } else { create_new_tab(false); } @@ -4341,6 +4362,9 @@ int runGitree(int argc, char** argv) { user_data.setSidebarCollapsed(g_sidebar_auto_collapsed ? g_sidebar_collapsed_before_viewer : g_sidebar_collapsed); user_data.setSidebarWidth(g_sidebar_width); user_data.setDetailsWidth(g_details_width); + user_data.setCommitMessageHeight(g_commit_message_height); + user_data.setWorkingComposerHeight(g_working_composer_height); + save_pending_commit_draft(); // persist any unsaved commit draft persist_repository_session(); user_data.save(); ImGui::DestroyContext();