From 1114c05cb96ea1b9048498d089003f56add0297e Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Fri, 19 Jun 2026 15:12:11 -0500 Subject: [PATCH] Not sure --- CMakeLists.txt | 2 +- src/managers/git_manager.cpp | 220 ++++++++++++++++++++++++++++++----- src/managers/git_manager.h | 69 +++++++---- src/managers/user_data.cpp | 185 +++++++++++++++++++++++++++++ src/managers/user_data.h | 15 +++ src/ui/diff_viewer.cpp | 100 +++++++++++----- src/ui/gitree_ui.cpp | 183 +++++++++++++++++++++++++++-- 7 files changed, 677 insertions(+), 97 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 62ab751..8779ab2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,7 +86,7 @@ target_compile_definitions(gitree PRIVATE ) if(WIN32) - target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt) + target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt crypt32) endif() if(MSVC) diff --git a/src/managers/git_manager.cpp b/src/managers/git_manager.cpp index b601a73..6637ca8 100644 --- a/src/managers/git_manager.cpp +++ b/src/managers/git_manager.cpp @@ -4,10 +4,12 @@ #include #include +#include #include #include #include #include +#include #include #ifdef _WIN32 @@ -99,6 +101,64 @@ namespace } } + std::vector repositoryRemotes(git_repository *repository) + { + std::vector remotes; + git_strarray names{}; + if (git_remote_list(&names, repository) == 0) + { + for (size_t i = 0; i < names.count; ++i) + remotes.emplace_back(names.strings[i]); + git_strarray_dispose(&names); + } + return remotes; + } + + std::string preferredRemoteName(git_repository *repository) + { + const std::vector remotes = repositoryRemotes(repository); + if (remotes.empty()) + return {}; + const auto origin = std::find(remotes.begin(), remotes.end(), "origin"); + return origin != remotes.end() ? *origin : remotes.front(); + } + +#ifdef _WIN32 + std::string encodeBase64(std::string_view value) + { + static constexpr std::string_view alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string encoded; + encoded.reserve(((value.size() + 2) / 3) * 4); + for (size_t index = 0; index < value.size(); index += 3) + { + const uint32_t a = static_cast(value[index]); + const uint32_t b = index + 1 < value.size() ? static_cast(value[index + 1]) : 0; + const uint32_t c = index + 2 < value.size() ? static_cast(value[index + 2]) : 0; + const uint32_t chunk = (a << 16) | (b << 8) | c; + encoded.push_back(alphabet[(chunk >> 18) & 0x3F]); + encoded.push_back(alphabet[(chunk >> 12) & 0x3F]); + encoded.push_back(index + 1 < value.size() ? alphabet[(chunk >> 6) & 0x3F] : '='); + encoded.push_back(index + 2 < value.size() ? alphabet[chunk & 0x3F] : '='); + } + return encoded; + } +#endif + + std::vector withAuthOverrideArguments(std::vector arguments, + const std::optional &auth) + { + if (!auth || auth->username.empty()) + return arguments; + const std::string header = "http.extraHeader=Authorization: Basic " + + encodeBase64(auth->username + ":" + auth->password); + arguments.insert(arguments.begin(), { + "-c", "credential.interactive=never", + "-c", header, + }); + return arguments; + } + void addBadge(RepositoryView &repository, const git_oid *oid, RefBadge badge) { if (!oid) @@ -753,10 +813,11 @@ bool GitManager::reload(RepositoryView &repository, std::string &error) } bool GitManager::runGit(RepositoryView &repository, const std::vector &arguments, - const char *success_message, std::string &message, bool reload_repository) + const char *success_message, std::string &message, bool reload_repository, + const std::optional &auth) { std::string command_output; - if (!captureGit(repository, arguments, command_output, message)) + if (!captureGit(repository, withAuthOverrideArguments(arguments, auth), command_output, message)) return false; if (reload_repository) { @@ -772,7 +833,8 @@ bool GitManager::runGit(RepositoryView &repository, const std::vector &arguments, - std::string &command_output, std::string &error) + std::string &command_output, std::string &error, + const std::string *stdin_data) { if (!repository.repo) { @@ -789,10 +851,24 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vectordata(), static_cast(stdin_data->size()), &bytes_written, nullptr); + CloseHandle(input_write); + input_write = nullptr; + } WaitForSingleObject(process.hProcess, INFINITE); GetExitCodeProcess(process.hProcess, &exit_code); CloseHandle(process.hThread); CloseHandle(process.hProcess); } + if (input_read) + CloseHandle(input_read); + if (input_write) + CloseHandle(input_write); FlushFileBuffers(output); SetFilePointer(output, 0, nullptr, FILE_BEGIN); command_output.clear(); @@ -851,7 +943,7 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err return true; bool uses_https = false; - for (const std::string &remote_name : repository.remotes) + for (const std::string &remote_name : repositoryRemotes(repository.repo)) { git_remote *remote = nullptr; if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) == 0) @@ -892,9 +984,8 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err if (!gcm_available) { - error = "This HTTPS remote needs authentication, but no credential helper is configured. " - "Install Git Credential Manager or configure credential.helper in Git."; - return false; + repository.credentials_checked = true; + return true; } std::string configure_output; @@ -905,6 +996,78 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err return true; } +std::string GitManager::remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error) +{ + git_remote *remote = nullptr; + if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) != 0) + { + error = lastError("Unable to find remote"); + return {}; + } + const char *url = git_remote_url(remote); + const std::string remote_url = url ? url : ""; + git_remote_free(remote); + if (remote_url.empty()) + error = "Remote has no URL"; + return remote_url; +} + +bool GitManager::storeCredential(RepositoryView &repository, const std::string &remote_url, + const std::string &username, const std::string &password, + std::string &error) +{ + if (username.empty()) + { + error = "Username is required"; + return false; + } + if (!prepareCredentials(repository, error)) + return false; + std::string helper; + std::string helper_error; + if (!captureGit(repository, {"config", "--get-all", "credential.helper"}, helper, helper_error) || helper.empty()) + { + error = helper_error.empty() ? "No Git credential helper is configured" : helper_error; + return false; + } + const size_t scheme = remote_url.find("://"); + if (scheme == std::string::npos) + { + error = "Remote URL is invalid"; + return false; + } + const size_t authority_begin = scheme + 3; + const size_t path_begin = remote_url.find('/', authority_begin); + const std::string protocol = remote_url.substr(0, scheme); + const std::string host = path_begin == std::string::npos + ? remote_url.substr(authority_begin) + : remote_url.substr(authority_begin, path_begin - authority_begin); + const std::string path = path_begin == std::string::npos ? "/" : remote_url.substr(path_begin); + std::ostringstream input; + input << "protocol=" << protocol << "\n"; + input << "host=" << host << "\n"; + input << "path=" << path << "\n"; + input << "username=" << username << "\n"; + input << "password=" << password << "\n\n"; + std::string output; + std::string payload = input.str(); + return captureGit(repository, {"credential", "approve"}, output, error, &payload); +} + +bool GitManager::isAuthenticationFailure(std::string_view message) +{ + std::string lowered(message); + std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char value) + { return static_cast(std::tolower(value)); }); + return lowered.find(" 403") != std::string::npos || + lowered.find(" 401") != std::string::npos || + lowered.find("forbidden") != std::string::npos || + lowered.find("authentication failed") != std::string::npos || + lowered.find("access denied") != std::string::npos || + lowered.find("could not read username") != std::string::npos || + lowered.find("terminal prompts disabled") != std::string::npos; +} + bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch, bool cached, bool reverse, std::string &error) { @@ -935,22 +1098,24 @@ bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch return applied; } -bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error) +bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error, + const std::optional &auth) { if (!prepareCredentials(repository, error)) return false; const std::vector args = remote.empty() ? std::vector{"fetch", "--all", "--prune"} : std::vector{"fetch", remote, "--prune"}; - return runGit(repository, args, "Fetch complete", error); + return runGit(repository, args, "Fetch complete", error, true, auth); } -bool GitManager::pull(RepositoryView &repository, int mode, std::string &error) +bool GitManager::pull(RepositoryView &repository, int mode, std::string &error, + const std::optional &auth) { if (!prepareCredentials(repository, error)) return false; if (mode == 0) - return fetch(repository, {}, error); + return fetch(repository, {}, error, auth); std::vector args{"pull"}; if (mode == 1) args.push_back("--ff"); @@ -958,26 +1123,25 @@ bool GitManager::pull(RepositoryView &repository, int mode, std::string &error) args.push_back("--ff-only"); else if (mode == 3) args.push_back("--rebase"); - return runGit(repository, args, "Pull complete", error); + return runGit(repository, args, "Pull complete", error, true, auth); } -bool GitManager::push(RepositoryView &repository, std::string &error) +bool GitManager::push(RepositoryView &repository, std::string &error, + const std::optional &auth) { if (!prepareCredentials(repository, error)) return false; - if (runGit(repository, {"push"}, "Push complete", error)) + if (runGit(repository, {"push"}, "Push complete", error, true, auth)) return true; - if (repository.remotes.empty()) + const std::string remote = preferredRemoteName(repository.repo); + if (remote.empty()) return false; - const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") != - repository.remotes.end() - ? "origin" - : repository.remotes.front(); return runGit(repository, {"push", "--set-upstream", remote, repository.branch}, - "Push complete; upstream configured", error); + "Push complete; upstream configured", error, true, auth); } -bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error) +bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error, + const std::optional &auth) { if (!prepareCredentials(repository, error)) return false; @@ -1009,20 +1173,16 @@ bool GitManager::pushBranch(RepositoryView &repository, const std::string &branc if (!remote_name.empty() && !remote_branch_name.empty()) return runGit(repository, {"push", remote_name, branch + ":" + remote_branch_name}, - "Push complete", error); + "Push complete", error, true, auth); - if (repository.remotes.empty()) + const std::string remote = preferredRemoteName(repository.repo); + if (remote.empty()) { error = "No remote is configured for this repository"; return false; } - - const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") != - repository.remotes.end() - ? "origin" - : repository.remotes.front(); return runGit(repository, {"push", "--set-upstream", remote, branch + ":" + branch}, - "Push complete; upstream configured", error); + "Push complete; upstream configured", error, true, auth); } bool GitManager::stash(RepositoryView &repository, std::string &error) diff --git a/src/managers/git_manager.h b/src/managers/git_manager.h index fd08dac..9fbccdf 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -1,12 +1,20 @@ #pragma once -#include "models/repository.h" -#include -#include - -class GitManager -{ -public: +#include "models/repository.h" +#include +#include +#include + +struct GitAuthOverride +{ + std::string remote_url; + std::string username; + std::string password; +}; + +class GitManager +{ +public: GitManager(); ~GitManager(); @@ -21,12 +29,16 @@ public: bool checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error); bool mergeBranch(RepositoryView &repository, const std::string &source_branch, const std::string &target_branch, std::string &error); - bool fetch(RepositoryView &repository, const std::string &remote, std::string &error); - bool pull(RepositoryView &repository, int mode, std::string &error); - bool push(RepositoryView &repository, std::string &error); - bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error); - bool stash(RepositoryView &repository, std::string &error); - bool popStash(RepositoryView &repository, std::string &error); + bool fetch(RepositoryView &repository, const std::string &remote, std::string &error, + const std::optional &auth = std::nullopt); + bool pull(RepositoryView &repository, int mode, std::string &error, + const std::optional &auth = std::nullopt); + bool push(RepositoryView &repository, std::string &error, + const std::optional &auth = std::nullopt); + bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error, + const std::optional &auth = std::nullopt); + bool stash(RepositoryView &repository, std::string &error); + bool popStash(RepositoryView &repository, std::string &error); bool undoCommit(RepositoryView &repository, std::string &error); bool redoCommit(RepositoryView &repository, std::string &error); bool stageAll(RepositoryView &repository, std::string &error); @@ -50,13 +62,19 @@ public: bool updateSubmodule(RepositoryView &repository, const std::string &name, std::string &error); std::string worktreePath(RepositoryView &repository, const std::string &name, std::string &error); bool loadCommitChanges(RepositoryView &repository, int commit_index, std::string &error); - bool loadCommitFiles(RepositoryView &repository, int commit_index, std::string &error); - bool captureGit(RepositoryView &repository, const std::vector &arguments, - std::string &output, std::string &error); - bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached, - bool reverse, std::string &error); - -private: + bool loadCommitFiles(RepositoryView &repository, int commit_index, std::string &error); + bool captureGit(RepositoryView &repository, const std::vector &arguments, + std::string &output, std::string &error, + const std::string *stdin_data = nullptr); + bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached, + bool reverse, std::string &error); + std::string remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error); + bool storeCredential(RepositoryView &repository, const std::string &remote_url, + const std::string &username, const std::string &password, + std::string &error); + static bool isAuthenticationFailure(std::string_view message); + +private: static std::string lastError(const char *fallback); static std::string formatTime(git_time_t value, int offset_minutes); void loadToolbarHistoryActions(RepositoryView &repository); @@ -65,8 +83,9 @@ private: void loadRefBadges(RepositoryView &repository); void computeGraphLanes(RepositoryView &repository); bool loadRepositoryData(RepositoryView &repository, std::string &error); - void loadWorkingTree(RepositoryView &repository); - bool prepareCredentials(RepositoryView &repository, std::string &error); - bool runGit(RepositoryView &repository, const std::vector &arguments, - const char *success_message, std::string &message, bool reload = true); -}; + void loadWorkingTree(RepositoryView &repository); + bool prepareCredentials(RepositoryView &repository, std::string &error); + bool runGit(RepositoryView &repository, const std::vector &arguments, + const char *success_message, std::string &message, bool reload = true, + const std::optional &auth = std::nullopt); +}; diff --git a/src/managers/user_data.cpp b/src/managers/user_data.cpp index ebff15f..7e55af5 100644 --- a/src/managers/user_data.cpp +++ b/src/managers/user_data.cpp @@ -10,8 +10,15 @@ extern "C" #include #include #include +#include +#include #include +#ifdef _WIN32 +#include +#include +#endif + namespace { std::filesystem::path roaming_directory() @@ -28,6 +35,89 @@ namespace 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'); + if (separator == std::string::npos) + return std::nullopt; + return std::pair{ + payload.substr(0, separator), + payload.substr(separator + 1)}; + } + +#ifdef _WIN32 + std::string hexEncode(const std::string &value) + { + static constexpr char digits[] = "0123456789abcdef"; + std::string encoded; + encoded.reserve(value.size() * 2); + for (const unsigned char byte : value) + { + encoded.push_back(digits[(byte >> 4) & 0x0F]); + encoded.push_back(digits[byte & 0x0F]); + } + return encoded; + } + + std::optional hexDecode(const std::string &value) + { + if (value.size() % 2 != 0) + return std::nullopt; + auto decode_nibble = [](char character) -> int + { + if (character >= '0' && character <= '9') + return character - '0'; + if (character >= 'a' && character <= 'f') + return 10 + (character - 'a'); + if (character >= 'A' && character <= 'F') + return 10 + (character - 'A'); + return -1; + }; + std::string decoded; + decoded.reserve(value.size() / 2); + for (size_t index = 0; index < value.size(); index += 2) + { + const int high = decode_nibble(value[index]); + const int low = decode_nibble(value[index + 1]); + if (high < 0 || low < 0) + return std::nullopt; + decoded.push_back(static_cast((high << 4) | low)); + } + return decoded; + } + + std::optional protectCredentialString(const std::string &value) + { + DATA_BLOB input{}; + input.pbData = reinterpret_cast(const_cast(value.data())); + input.cbData = static_cast(value.size()); + DATA_BLOB output{}; + if (!CryptProtectData(&input, L"Gitree Git Credential", nullptr, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) + return std::nullopt; + std::string protected_value(reinterpret_cast(output.pbData), output.cbData); + LocalFree(output.pbData); + return hexEncode(protected_value); + } + + std::optional unprotectCredentialString(const std::string &value) + { + const auto binary = hexDecode(value); + if (!binary) + return std::nullopt; + DATA_BLOB input{}; + input.pbData = reinterpret_cast(const_cast(binary->data())); + input.cbData = static_cast(binary->size()); + DATA_BLOB output{}; + if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) + return std::nullopt; + std::string plain(reinterpret_cast(output.pbData), output.cbData); + LocalFree(output.pbData); + return plain; + } +#endif } UserData::UserData() @@ -42,6 +132,16 @@ UserData::~UserData() save(); } +std::string UserData::credentialScope(const std::string &remote_url) +{ + const size_t scheme = remote_url.find("://"); + if (scheme == std::string::npos) + return remote_url; + const size_t authority_begin = scheme + 3; + const size_t authority_end = remote_url.find_first_of("/?#", authority_begin); + return remote_url.substr(0, authority_end == std::string::npos ? remote_url.size() : authority_end); +} + std::filesystem::path UserData::dataPath() const { return directory_ / "user_data.ikv2b"; @@ -126,6 +226,24 @@ void UserData::load() } } } + 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); @@ -212,6 +330,24 @@ void UserData::load() } } } + 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); @@ -280,6 +416,24 @@ void UserData::load() save(); } +std::optional UserData::remoteCredential(const std::string &remote_url) const +{ + const auto iterator = encrypted_credentials_.find(credentialScope(remote_url)); + if (iterator == encrypted_credentials_.end()) + return std::nullopt; +#ifdef _WIN32 + const auto plain = unprotectCredentialString(iterator->second); + if (!plain) + return std::nullopt; + const auto split = splitCredentialPayload(*plain); + if (!split) + return std::nullopt; + return SavedGitCredential{remote_url, split->first, split->second}; +#else + return std::nullopt; +#endif +} + void UserData::addRecentRepository(const std::string &path) { if (path.empty()) @@ -325,6 +479,29 @@ void UserData::setRepositorySession(std::vector paths, size_t activ save(); } +void UserData::storeRemoteCredential(const std::string &remote_url, const std::string &username, + const std::string &password) +{ + const std::string scope = credentialScope(remote_url); + if (scope.empty() || username.empty()) + return; +#ifdef _WIN32 + const auto protected_value = protectCredentialString(username + "\n" + password); + if (!protected_value) + return; + encrypted_credentials_[scope] = *protected_value; + save(); +#else + (void)password; +#endif +} + +void UserData::clearRemoteCredential(const std::string &remote_url) +{ + encrypted_credentials_.erase(credentialScope(remote_url)); + save(); +} + void UserData::save() const { std::filesystem::create_directories(directory_); @@ -362,6 +539,14 @@ void UserData::save() const for (const auto &path : open_repositories_) ikv_array_add_string(tabs, path.c_str()); + ikv_node_t *credentials = ikv_object_add_array(root, "credentials", IKV_OBJECT); + for (const auto &[scope, secret] : encrypted_credentials_) + { + ikv_node_t *entry = ikv_array_add_object(credentials); + ikv_object_set_string(entry, "scope", scope.c_str()); + ikv_object_set_string(entry, "secret", secret.c_str()); + } + ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2); ikv_free(root); removeLegacyFiles(); diff --git a/src/managers/user_data.h b/src/managers/user_data.h index d74b27f..61305f8 100644 --- a/src/managers/user_data.h +++ b/src/managers/user_data.h @@ -2,9 +2,18 @@ #include #include +#include #include +#include #include +struct SavedGitCredential +{ + std::string remote_url; + std::string username; + std::string password; +}; + class UserData { public: @@ -24,6 +33,8 @@ public: [[nodiscard]] float commitTableColumnWidth(size_t index) const { return commit_table_column_widths_.at(index); } [[nodiscard]] int pullMode() const { return pull_mode_; } [[nodiscard]] int zoomPercent() const { return zoom_percent_; } + [[nodiscard]] std::optional remoteCredential(const std::string &remote_url) const; + static std::string credentialScope(const std::string &remote_url); void setSidebarWidth(float width) { sidebar_width_ = width; } void setDetailsWidth(float width) { details_width_ = width; } @@ -45,6 +56,9 @@ public: void addRecentlyClosed(const std::string &path); std::string takeRecentlyClosed(); void setRepositorySession(std::vector paths, size_t active_repository); + void storeRemoteCredential(const std::string &remote_url, const std::string &username, + const std::string &password); + void clearRemoteCredential(const std::string &remote_url); void save() const; private: @@ -65,4 +79,5 @@ private: 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 encrypted_credentials_; }; diff --git a/src/ui/diff_viewer.cpp b/src/ui/diff_viewer.cpp index 930a772..0587043 100644 --- a/src/ui/diff_viewer.cpp +++ b/src/ui/diff_viewer.cpp @@ -518,27 +518,48 @@ std::vector buildMinimapSegments(const std::string& text, Syntax return segments; } -void drawMinimap(const std::vector& entries, const ImVec2& size, float scale, - float rendered_content_height, float visible_start, float visible_height, float max_scroll_y) { +ImU32 dimMinimapColor(ImU32 color) { + ImVec4 rgba = ImGui::ColorConvertU32ToFloat4(color); + rgba.x *= 0.58f; + rgba.y *= 0.58f; + rgba.z *= 0.58f; + rgba.w *= 0.82f; + return ImGui::ColorConvertFloat4ToU32(rgba); +} + +float minimapTotalUnits(const std::vector& entries) { + float total_units = 0.0f; + for (const MinimapEntry& entry : entries) total_units += std::max(0.10f, entry.units); + return std::max(1.0f, total_units); +} + +void drawMinimap(const std::vector& entries, const ImVec2& viewport_size, float scale, + float rendered_content_height, float visible_start, float visible_height, float max_scroll_y, + float content_height_override = 0.0f) { const ImVec2 minimum = ImGui::GetCursorScreenPos(); - ImGui::InvisibleButton("##code_minimap", size); + const float total_units = minimapTotalUnits(entries); + const float min_unit_height = scaled(0.72f, scale); + const float ideal_unit_height = scaled(2.05f, scale); + const float unit_height = content_height_override > 0.0f + ? std::max(min_unit_height, content_height_override / total_units) + : ideal_unit_height; + const float content_height = std::max(content_height_override, total_units * unit_height); + const ImVec2 content_size{viewport_size.x, content_height}; + ImGui::InvisibleButton("##code_minimap", content_size); const bool hovered = ImGui::IsItemHovered(); const bool active = ImGui::IsItemActive(); ImDrawList* draw = ImGui::GetWindowDrawList(); - draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(31, 33, 39, 235), scaled(4.0f, scale)); - draw->AddRect(minimum, {minimum.x + size.x, minimum.y + size.y}, - hovered || active ? IM_COL32(82, 90, 103, 255) : IM_COL32(56, 61, 71, 255), scaled(4.0f, scale)); + draw->AddRectFilled(minimum, {minimum.x + viewport_size.x, minimum.y + content_size.y}, + IM_COL32(26, 28, 33, 188), scaled(3.0f, scale)); + draw->AddRect(minimum, {minimum.x + viewport_size.x, minimum.y + content_size.y}, + hovered || active ? IM_COL32(70, 76, 88, 180) : IM_COL32(46, 50, 58, 140), scaled(3.0f, scale)); - if (entries.empty() || size.y <= 2.0f) return; + if (entries.empty() || content_size.y <= 1.0f) return; const float content_left = minimum.x + scaled(5.0f, scale); - const float content_right = minimum.x + size.x - scaled(5.0f, scale); + const float content_right = minimum.x + viewport_size.x - scaled(5.0f, scale); const float content_width = std::max(1.0f, content_right - content_left); - float total_units = 0.0f; - for (const MinimapEntry& entry : entries) total_units += std::max(0.10f, entry.units); - total_units = std::max(1.0f, total_units); - const float unit_height = size.y / total_units; - const float line_height = std::max(1.0f, std::min(scaled(3.2f, scale), unit_height)); + const float line_height = std::max(scaled(0.65f, scale), std::min(scaled(2.1f, scale), unit_height)); float y = minimum.y; for (const MinimapEntry& entry : entries) { const float entry_units = std::max(0.10f, entry.units); @@ -552,29 +573,29 @@ void drawMinimap(const std::vector& entries, const ImVec2& size, f } const float width = std::max(scaled(1.0f, scale), content_width * (segment.weight / total_weight)); draw->AddRectFilled({x, y}, {std::min(content_right, x + width), y + line_height}, - segment.color, scaled(1.0f, scale)); + dimMinimapColor(segment.color), scaled(1.0f, scale)); x += width; } y += entry_units * unit_height; } - const float content_height = std::max(rendered_content_height, 1.0f); - const float clamped_visible_height = std::clamp(visible_height, 0.0f, content_height); - const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, content_height - clamped_visible_height)); - const float viewport_height = std::clamp(size.y * (clamped_visible_height / content_height), - scaled(18.0f, scale), size.y); - const float viewport_y = minimum.y + size.y * (clamped_visible_start / content_height); + const float rendered_height = std::max(rendered_content_height, 1.0f); + const float clamped_visible_height = std::clamp(visible_height, 0.0f, rendered_height); + const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, rendered_height - clamped_visible_height)); + const float viewport_height = std::clamp(content_size.y * (clamped_visible_height / rendered_height), + scaled(14.0f, scale), content_size.y); + const float viewport_y = minimum.y + content_size.y * (clamped_visible_start / rendered_height); if ((hovered || active) && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - const float mouse_ratio = std::clamp((ImGui::GetIO().MousePos.y - minimum.y) / std::max(1.0f, size.y), 0.0f, 1.0f); - const float target = std::clamp(mouse_ratio * content_height - clamped_visible_height * 0.5f, 0.0f, max_scroll_y); + const float mouse_ratio = std::clamp((ImGui::GetIO().MousePos.y - minimum.y) / std::max(1.0f, content_size.y), 0.0f, 1.0f); + const float target = std::clamp(mouse_ratio * rendered_height - clamped_visible_height * 0.5f, 0.0f, max_scroll_y); ImGui::SetScrollY(target); } draw->AddRectFilled({minimum.x + scaled(1.0f, scale), viewport_y}, - {minimum.x + size.x - scaled(1.0f, scale), viewport_y + viewport_height}, - IM_COL32(120, 146, 198, hovered || active ? 62 : 42), scaled(2.0f, scale)); + {minimum.x + viewport_size.x - scaled(1.0f, scale), viewport_y + viewport_height}, + IM_COL32(109, 129, 170, hovered || active ? 44 : 28), scaled(2.0f, scale)); draw->AddRect({minimum.x + scaled(1.0f, scale), viewport_y}, - {minimum.x + size.x - scaled(1.0f, scale), viewport_y + viewport_height}, - IM_COL32(120, 146, 198, 185), scaled(2.0f, scale)); + {minimum.x + viewport_size.x - scaled(1.0f, scale), viewport_y + viewport_height}, + IM_COL32(120, 138, 170, 120), scaled(2.0f, scale)); } void drawCodeLine(const std::string& text, SyntaxLanguage language, SyntaxState& syntax, @@ -965,7 +986,8 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac } ImGuiWindowFlags content_flags = line_wrap_ ? ImGuiWindowFlags_None : ImGuiWindowFlags_HorizontalScrollbar; - if (scroll_to_top_) ImGui::SetNextWindowScroll({0.0f, 0.0f}); + const bool request_scroll_to_top = scroll_to_top_; + if (request_scroll_to_top) ImGui::SetNextWindowScroll({0.0f, 0.0f}); ImGui::BeginChild("diff_content_main", ImVec2{-1, -1}, ImGuiChildFlags_None, content_flags); scroll_to_top_ = false; const bool use_code_font = code_font && mode_ != Mode::history; @@ -1160,14 +1182,28 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac rendered_content_height = ImGui::GetCursorPosY(); const float max_scroll_y = ImGui::GetScrollMaxY(); if (show_minimap) { - const float minimap_height = std::max(scaled(40.0f, scale), child_size.y - scaled(10.0f, scale)); + const float total_units = minimapTotalUnits(minimap_entries); + const float min_unit_height = scaled(0.72f, scale); + const float ideal_unit_height = scaled(2.05f, scale); + const float max_minimap_height = std::max(scaled(24.0f, scale), child_size.y - scaled(8.0f, scale)); + const float fitted_unit_height = std::clamp(max_minimap_height / total_units, min_unit_height, ideal_unit_height); + const float minimap_content_height = total_units * fitted_unit_height; + const float minimap_height = std::min(max_minimap_height, minimap_content_height); const float minimap_x = child_minimum.x + child_size.x - scrollbar_width - minimap_width - scaled(4.0f, scale); const float minimap_y = child_minimum.y + scaled(4.0f, scale); ImGui::SetCursorScreenPos({minimap_x, minimap_y}); - ImGui::PushClipRect(child_minimum, {child_minimum.x + child_size.x, child_minimum.y + child_size.y}, true); - drawMinimap(minimap_entries, {minimap_width, minimap_height}, scale, - rendered_content_height, main_scroll_y, main_window_height, max_scroll_y); - ImGui::PopClipRect(); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, ImVec4(0.28f, 0.31f, 0.37f, 0.55f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(0.36f, 0.40f, 0.47f, 0.72f)); + ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(0.44f, 0.49f, 0.57f, 0.86f)); + if (request_scroll_to_top) ImGui::SetNextWindowScroll({0.0f, 0.0f}); + ImGui::BeginChild("diff_content_minimap", {minimap_width, minimap_height}, + ImGuiChildFlags_Borders, ImGuiWindowFlags_NoMove); + drawMinimap(minimap_entries, {minimap_width - scaled(1.0f, scale), minimap_height}, scale, + rendered_content_height, main_scroll_y, main_window_height, max_scroll_y, minimap_content_height); + ImGui::EndChild(); + ImGui::PopStyleColor(5); } if (use_code_font) ImGui::PopFont(); ImGui::EndChild(); diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index df1c8ce..da01862 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -60,9 +60,12 @@ bool g_remote_add_popup = false; bool g_worktree_add_popup = false; bool g_submodule_add_popup = false; bool g_discard_all_popup = false; +bool g_git_auth_popup = false; std::array g_git_name{}; std::array g_git_value{}; std::array g_git_path{}; +std::array g_git_auth_username{}; +std::array g_git_auth_password{}; std::array g_repository_filter{}; std::array g_create_repository_name{}; std::array g_create_repository_parent{}; @@ -79,6 +82,9 @@ std::string g_merge_branch_source; std::string g_merge_branch_target; bool g_merge_branch_popup = false; std::string g_pending_commit_jump; +std::string g_git_auth_remote_url; +std::string g_git_auth_error; +bool g_git_auth_remember = true; RepositoryView* g_expanded_refs_repository = nullptr; int g_expanded_refs_commit = -1; RepositoryView* g_inline_branch_repository = nullptr; @@ -140,6 +146,8 @@ struct GitAsyncRequest { std::string success_notice; std::string focus_commit; std::string preserve_selected_hash; + std::optional auth_override; + bool used_saved_app_credential = false; int pull_mode = 1; bool surface_errors = false; }; @@ -152,10 +160,14 @@ struct GitAsyncResult { std::string notice; std::string focus_commit; std::string preserve_selected_hash; + GitAsyncRequest retry_request; + bool auth_required = false; bool surface_errors = false; std::unique_ptr snapshot; }; +std::optional g_git_auth_retry_request; + std::future g_git_async_future; enum class ToastKind { info, success, warning, error }; @@ -387,6 +399,37 @@ void mark_repository_refreshed(RepositoryView& repository) { repository.pending_background_refresh = false; } +std::string preferred_auth_remote_name(const RepositoryView& repository, const std::string& remote_hint = {}) { + if (!remote_hint.empty()) return remote_hint; + const auto origin = std::find(repository.remotes.begin(), repository.remotes.end(), "origin"); + if (origin != repository.remotes.end()) return *origin; + return repository.remotes.empty() ? std::string{} : repository.remotes.front(); +} + +void attach_saved_auth_if_available(RepositoryView& repository, GitAsyncRequest& request) { + if (!g_user_data || !g_git_manager) return; + const std::string remote_name = preferred_auth_remote_name(repository, request.remote); + if (remote_name.empty()) return; + std::string remote_error; + const std::string remote_url = g_git_manager->remoteUrl(repository, remote_name, remote_error); + if (remote_url.empty()) return; + const auto saved = g_user_data->remoteCredential(remote_url); + if (!saved) return; + request.auth_override = GitAuthOverride{remote_url, saved->username, saved->password}; + request.used_saved_app_credential = true; +} + +void attach_auth_remote_metadata(RepositoryView& repository, GitAsyncRequest& request) { + if (!g_git_manager) return; + const std::string remote_name = preferred_auth_remote_name(repository, request.remote); + if (remote_name.empty()) return; + std::string remote_error; + const std::string remote_url = g_git_manager->remoteUrl(repository, remote_name, remote_error); + if (remote_url.empty()) return; + if (request.auth_override) request.auth_override->remote_url = remote_url; + else request.auth_override = GitAuthOverride{remote_url, {}, {}}; +} + GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) { GitAsyncResult result; result.operation = request.operation; @@ -395,6 +438,7 @@ GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) { result.focus_commit = request.focus_commit; result.preserve_selected_hash = request.preserve_selected_hash; result.surface_errors = request.surface_errors; + result.retry_request = request; GitManager manager; RepositoryView repository; @@ -413,19 +457,19 @@ GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) { break; } case GitAsyncOperation::pull: - action_ok = manager.pull(repository, request.pull_mode, result.notice); + action_ok = manager.pull(repository, request.pull_mode, result.notice, request.auth_override); break; case GitAsyncOperation::push: - action_ok = manager.push(repository, result.notice); + action_ok = manager.push(repository, result.notice, request.auth_override); break; case GitAsyncOperation::checkout_branch: action_ok = manager.checkoutBranch(repository, request.branch, result.notice); break; case GitAsyncOperation::push_branch: - action_ok = manager.pushBranch(repository, request.branch, result.notice); + action_ok = manager.pushBranch(repository, request.branch, result.notice, request.auth_override); break; case GitAsyncOperation::fetch: - action_ok = manager.fetch(repository, request.remote, result.notice); + action_ok = manager.fetch(repository, request.remote, result.notice, request.auth_override); break; case GitAsyncOperation::stash: action_ok = manager.stash(repository, result.notice); @@ -434,7 +478,15 @@ GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) { action_ok = manager.popStash(repository, result.notice); break; } - if (!action_ok) return result; + if (!action_ok) { + const bool auth_operation = request.operation == GitAsyncOperation::fetch || + request.operation == GitAsyncOperation::pull || + request.operation == GitAsyncOperation::push || + request.operation == GitAsyncOperation::push_branch; + if (auth_operation && GitManager::isAuthenticationFailure(result.notice)) + result.auth_required = true; + return result; + } auto snapshot = std::make_unique(); transfer_repository_state(repository, *snapshot); @@ -448,6 +500,25 @@ void apply_git_async_result(GitAsyncResult result) { g_running_toolbar_action = ToolbarActionRequest::none; if (result.operation == GitAsyncOperation::checkout_branch) g_running_branch_checkout.clear(); + if (result.auth_required) { + g_git_auth_retry_request = result.retry_request; + g_git_auth_error = result.notice; + g_git_auth_remote_url = result.retry_request.auth_override + ? result.retry_request.auth_override->remote_url + : g_git_auth_remote_url; + if (g_user_data && result.retry_request.used_saved_app_credential && !g_git_auth_remote_url.empty()) + g_user_data->clearRemoteCredential(g_git_auth_remote_url); + if (result.retry_request.auth_override) { + std::snprintf(g_git_auth_username.data(), g_git_auth_username.size(), "%s", + result.retry_request.auth_override->username.c_str()); + std::fill(g_git_auth_password.begin(), g_git_auth_password.end(), '\0'); + } else { + std::fill(g_git_auth_username.begin(), g_git_auth_username.end(), '\0'); + std::fill(g_git_auth_password.begin(), g_git_auth_password.end(), '\0'); + } + g_git_auth_popup = true; + return; + } RepositoryView* target = find_repository_tab(result.tab); if (!target || target->path != result.repo_path) { if ((result.success || result.surface_errors) && !result.notice.empty()) g_notice = result.notice; @@ -642,6 +713,8 @@ bool start_push_branch_async(RepositoryView& repository, const std::string& bran request.repo_path = repository.path; request.branch = branch; request.preserve_selected_hash = selected_commit_hash(repository); + attach_auth_remote_metadata(repository, request); + attach_saved_auth_if_available(repository, request); return start_git_async_request(std::move(request)); } @@ -653,6 +726,8 @@ bool start_fetch_async(RepositoryView& repository, const std::string& remote, bo request.remote = remote; request.surface_errors = surface_errors; request.preserve_selected_hash = selected_commit_hash(repository); + attach_auth_remote_metadata(repository, request); + attach_saved_auth_if_available(repository, request); return start_git_async_request(std::move(request), "A Git operation is already running"); } @@ -669,6 +744,8 @@ bool start_toolbar_action_async(RepositoryView& repository, ToolbarActionRequest } else { return false; } + attach_auth_remote_metadata(repository, request); + attach_saved_auth_if_available(repository, request); return start_git_async_request(std::move(request)); } @@ -2856,8 +2933,21 @@ void draw_details(float width) { ImGui::PopStyleColor(); } +void draw_toolbar_spinner(ImDrawList* draw, const ImVec2& center, float radius, ImU32 color) { + const float start = static_cast(ImGui::GetTime() * 6.0); + const float span = 4.4f; + constexpr int segments = 24; + draw->PathClear(); + for (int index = 0; index < segments; ++index) { + const float t = static_cast(index) / static_cast(segments - 1); + const float angle = start + span * t; + draw->PathLineTo({center.x + std::cos(angle) * radius, center.y + std::sin(angle) * radius}); + } + draw->PathStroke(color, false, ui(2.0f)); +} + bool toolbar_action(const char* id, const char* label, const char* icon, const char* tooltip, - bool enabled = true, bool dropdown = false, float logical_width = 58.0f, + bool enabled = true, bool dropdown = false, float logical_width = 58.0f, bool spinning = false, bool* dropdown_clicked = nullptr) { ImGui::PushID(id); if (!enabled) ImGui::BeginDisabled(); @@ -2884,7 +2974,15 @@ bool toolbar_action(const char* id, const char* label, const char* icon, const c {minimum.x + (maximum.x - minimum.x - label_size.x) * 0.5f, minimum.y + ui(4.0f)}, text_color, label); const float icon_x = minimum.x + (maximum.x - minimum.x - icon_size.x) * 0.5f - (dropdown ? ui(4.0f) : 0.0f); - draw->AddText(g_regular_font, icon_font_size, {icon_x, minimum.y + ui(19.0f)}, text_color, icon); + if (spinning) { + const ImVec2 center{ + minimum.x + (maximum.x - minimum.x) * 0.5f - (dropdown ? ui(4.0f) : 0.0f), + minimum.y + ui(27.0f) + }; + draw_toolbar_spinner(draw, center, ui(7.0f), text_color); + } else { + draw->AddText(g_regular_font, icon_font_size, {icon_x, minimum.y + ui(19.0f)}, text_color, icon); + } if (dropdown) draw->AddText({icon_x + icon_size.x + ui(7.0f), minimum.y + ui(21.0f)}, text_color, ICON_TB_CARET_DOWN); if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { @@ -3184,8 +3282,75 @@ void draw_licenses_popup() { ImGui::EndPopup(); } +void draw_git_credentials_popup() { + if (g_git_auth_popup) { + ImGui::OpenPopup("Git credentials required"); + g_git_auth_popup = false; + } + if (!ImGui::BeginPopupModal("Git credentials required", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; + + ImGui::TextWrapped("Authentication is required for this remote."); + if (!g_git_auth_remote_url.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("Remote"); + ImGui::TextWrapped("%s", g_git_auth_remote_url.c_str()); + } + if (!g_git_auth_error.empty()) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.95f, 0.47f, 0.47f, 1.0f), "%s", g_git_auth_error.c_str()); + } + + ImGui::Spacing(); + ImGui::SetNextItemWidth(ui(320.0f)); + ImGui::InputText("Username", g_git_auth_username.data(), g_git_auth_username.size()); + ImGui::SetNextItemWidth(ui(320.0f)); + ImGui::InputText("Password", g_git_auth_password.data(), g_git_auth_password.size(), + ImGuiInputTextFlags_Password); + ImGui::Checkbox("Remember securely", &g_git_auth_remember); + + const bool can_retry = g_git_auth_retry_request.has_value() && g_git_auth_username[0] != '\0'; + if (!can_retry) ImGui::BeginDisabled(); + if (ImGui::Button("Retry", {ui(110.0f), 0.0f})) { + GitAsyncRequest request = *g_git_auth_retry_request; + request.auth_override = GitAuthOverride{ + g_git_auth_remote_url, + g_git_auth_username.data(), + g_git_auth_password.data(), + }; + request.used_saved_app_credential = false; + if (g_git_auth_remember && g_user_data && !g_git_auth_remote_url.empty()) { + RepositoryView* target = find_repository_tab(request.tab); + std::string store_error; + bool stored_in_helper = false; + if (target && g_git_manager) + stored_in_helper = g_git_manager->storeCredential(*target, g_git_auth_remote_url, + request.auth_override->username, request.auth_override->password, store_error); + if (stored_in_helper) g_user_data->clearRemoteCredential(g_git_auth_remote_url); + else g_user_data->storeRemoteCredential(g_git_auth_remote_url, + request.auth_override->username, request.auth_override->password); + } else if (g_user_data && !g_git_auth_remote_url.empty()) { + g_user_data->clearRemoteCredential(g_git_auth_remote_url); + } + if (start_git_async_request(std::move(request))) { + std::fill(g_git_auth_password.begin(), g_git_auth_password.end(), '\0'); + g_git_auth_retry_request.reset(); + ImGui::CloseCurrentPopup(); + } + } + if (!can_retry) ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel", {ui(110.0f), 0.0f})) { + g_git_auth_retry_request.reset(); + std::fill(g_git_auth_password.begin(), g_git_auth_password.end(), '\0'); + g_notice = "Authentication cancelled"; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + void draw_git_action_popups() { static bool checkout_new_branch = true; + draw_git_credentials_popup(); if (g_merge_branch_popup) { g_merge_branch_popup = false; ImGui::OpenPopup("Merge branches"); @@ -3963,14 +4128,14 @@ void draw_app() { const bool pull_running = g_running_toolbar_action == ToolbarActionRequest::pull; if (toolbar_action("pull", "Pull", pull_running ? ICON_TB_ROTATE_RIGHT : ICON_TB_DOWNLOAD, pull_running ? "Pull in progress..." : pull_mode_name(g_user_data->pullMode()), - !toolbar_busy, true, 58, &pull_dropdown_clicked)) + !toolbar_busy, true, 58, pull_running, &pull_dropdown_clicked)) g_pending_toolbar_action = ToolbarActionRequest::pull; if (pull_dropdown_clicked) ImGui::OpenPopup("pull_options"); draw_pull_options(); ImGui::SameLine(0, action_spacing); const bool push_running = g_running_toolbar_action == ToolbarActionRequest::push; if (toolbar_action("push", "Push", push_running ? ICON_TB_ROTATE_RIGHT : ICON_TB_UPLOAD, - push_running ? "Push in progress..." : "Push to remote", !toolbar_busy, false, 58)) + push_running ? "Push in progress..." : "Push to remote", !toolbar_busy, false, 58, push_running)) g_pending_toolbar_action = ToolbarActionRequest::push; ImGui::SameLine(0, action_spacing); if (toolbar_action("branch_action", "Branch", ICON_TB_CODE_BRANCH, "Create branch", true, false, 64)) {