fix(user-data): persist session, drafts, and layout state

This commit is contained in:
2026-06-19 16:54:36 -05:00
parent ac9df86ef0
commit ce81922ebb
3 changed files with 331 additions and 319 deletions

View File

@@ -1,7 +1,7 @@
#include "user_data.h"
#include <izo/Paths.hpp>
#include <ikvxx/ikvxx.hpp>
extern "C"
{
#include <ikv.h>
@@ -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<std::pair<std::string, std::string>> 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<float, 4> &sidebar_section_heights,
std::array<bool, 4> &sidebar_section_open,
std::array<float, 4> &commit_table_column_widths)
{
if (settings.isMember("sidebar_width") && settings["sidebar_width"].isDouble())
sidebar_width = static_cast<float>(settings["sidebar_width"].asDouble());
if (settings.isMember("details_width") && settings["details_width"].isDouble())
details_width = static_cast<float>(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<float>(settings["commit_message_height"].asDouble());
if (settings.isMember("working_composer_height") && settings["working_composer_height"].isDouble())
working_composer_height = static_cast<float>(settings["working_composer_height"].asDouble());
if (settings.isMember("pull_mode") && settings["pull_mode"].isInt())
pull_mode = static_cast<int>(settings["pull_mode"].asInt64());
if (settings.isMember("zoom_percent") && settings["zoom_percent"].isInt())
zoom_percent = static_cast<int>(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<float>(arr[static_cast<ArrayIndex>(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<ArrayIndex>(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<float>(arr[static_cast<ArrayIndex>(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<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(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<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(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<uint32_t>(
ikv_array_size(open), static_cast<uint32_t>(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<uint32_t>(
ikv_array_size(widths), 4);
for (uint32_t index = 0; index < count; ++index)
commit_table_column_widths_[index] = static_cast<float>(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<float, 4> &arr = repo_column_widths_[normalized_path];
arr = commit_table_column_widths_;
const uint32_t count = std::min<uint32_t>(ikv_array_size(widths_node), 4);
for (uint32_t c = 0; c < count; ++c)
arr[c] = static_cast<float>(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<uint32_t>(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<std::size_t>(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<ArrayIndex>(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<uint32_t>(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<std::size_t>(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<ArrayIndex>(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<size_t>(std::max<int64_t>(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<size_t>(std::max<int64_t>(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<ArrayIndex>(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<ArrayIndex>(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<std::size_t>(widths.size(), 4);
for (std::size_t c = 0; c < count; ++c)
rs.column_widths[c] = static_cast<float>(widths[static_cast<ArrayIndex>(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<ArrayIndex>(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<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(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<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(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<uint32_t>(
ikv_array_size(open), static_cast<uint32_t>(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<uint32_t>(
ikv_array_size(widths), static_cast<uint32_t>(commit_table_column_widths_.size()));
for (uint32_t index = 0; index < count; ++index)
commit_table_column_widths_[index] = static_cast<float>(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<uint32_t>(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<uint32_t>(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<size_t>(std::max<int64_t>(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<size_t>(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<SavedGitCredential> UserData::remoteCredential(const std::string &remote_url) const
@@ -503,10 +441,23 @@ std::optional<SavedGitCredential> 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<int64_t>(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<float, 4> &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();
}

View File

@@ -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<SavedGitCredential> 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<float, 4> 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<std::string> recent_repositories_;
@@ -76,9 +93,11 @@ private:
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
bool sidebar_collapsed_ = false;
std::array<bool, 4> sidebar_section_open_ = {true, true, true, true};
std::unordered_map<std::string, std::array<float, 4>> repo_column_widths_;
float commit_message_height_ = 125.0f;
float working_composer_height_ = 320.0f;
std::array<float, 4> commit_table_column_widths_ = {0.0f, 0.0f, 0.0f, 0.0f};
int pull_mode_ = 1;
int zoom_percent_ = 100;
std::unordered_map<std::string, RepoSettings> repo_settings_;
std::unordered_map<std::string, std::string> encrypted_credentials_;
};

View File

@@ -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<std::string> 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<RepositoryView>());
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<std::string> 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<std::string> repositories;
std::set<std::string> 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();