From 0152667b655b4962db34dd5e22d743d40692ce04 Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Thu, 18 Jun 2026 21:25:51 -0500 Subject: [PATCH] feat(app): polish repository workflows and graph --- .gitea/workflows/windows-build.yml | 13 +- CMakeLists.txt | 2 + src/managers/application_icon_cache.cpp | 97 +++ src/managers/application_icon_cache.h | 22 + src/managers/application_manager.cpp | 145 ++-- src/managers/application_manager.h | 21 +- src/managers/avatar_cache.cpp | 130 +-- src/managers/avatar_cache.h | 18 +- src/managers/git_manager.cpp | 997 +++++++++++++++++------- src/managers/git_manager.h | 95 +-- src/managers/user_data.cpp | 175 +++-- src/managers/user_data.h | 19 +- src/managers/window_manager.h | 15 +- src/models/repository.h | 14 + src/ui/diff_viewer.cpp | 455 +++++++++-- src/ui/diff_viewer.h | 15 +- src/ui/gitree_ui.cpp | 813 ++++++++++++++----- src/ui/graph_renderer.cpp | 134 +++- src/ui/graph_renderer.h | 2 +- 19 files changed, 2361 insertions(+), 821 deletions(-) create mode 100644 src/managers/application_icon_cache.cpp create mode 100644 src/managers/application_icon_cache.h diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index 8388b2a..8937591 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -38,11 +38,22 @@ jobs: run: | cmake -S . -B build-win -G Ninja \ -DCMAKE_TOOLCHAIN_FILE=mingw-toolchain.cmake \ - -DCMAKE_BUILD_TYPE=Release + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_EXE_LINKER_FLAGS="-static -static-libgcc -static-libstdc++" - name: Build Windows executable run: cmake --build build-win --parallel + - name: Verify static runtime linkage + run: | + dependencies="$(x86_64-w64-mingw32-objdump -p build-win/bin/gitree.exe | sed -n 's/.*DLL Name: //p')" + printf '%s\n' "$dependencies" + if printf '%s\n' "$dependencies" | grep -Eqi 'lib(gcc|stdc\+\+|winpthread).*\.dll'; then + echo "The executable still depends on a MinGW runtime DLL." + exit 1 + fi + - name: Prepare artifact run: | mkdir -p dist diff --git a/CMakeLists.txt b/CMakeLists.txt index e6e1984..75afaa9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,8 @@ add_executable(gitree WIN32 src/managers/avatar_cache.h src/managers/application_manager.cpp src/managers/application_manager.h + src/managers/application_icon_cache.cpp + src/managers/application_icon_cache.h src/models/repository.h ) target_include_directories(gitree PRIVATE src vendor/libgit2/include vendor/icons) diff --git a/src/managers/application_icon_cache.cpp b/src/managers/application_icon_cache.cpp new file mode 100644 index 0000000..e5bad88 --- /dev/null +++ b/src/managers/application_icon_cache.cpp @@ -0,0 +1,97 @@ +#include "application_icon_cache.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +ApplicationIconCache::~ApplicationIconCache() +{ + shutdown(); +} + +unsigned int ApplicationIconCache::textureFor(const std::filesystem::path &executable) +{ + if (executable.empty()) + return 0; + const std::string key = executable.string(); + const auto found = textures_.find(key); + if (found != textures_.end()) + return found->second; + const unsigned int texture = loadTexture(executable); + textures_.emplace(key, texture); + return texture; +} + +unsigned int ApplicationIconCache::loadTexture(const std::filesystem::path &executable) const +{ +#ifdef _WIN32 + HICON large_icon = nullptr; + HICON small_icon = nullptr; + if (ExtractIconExW(executable.wstring().c_str(), 0, &large_icon, &small_icon, 1) == 0) + return 0; + HICON icon = large_icon ? large_icon : small_icon; + if (!icon) + return 0; + + constexpr int size = 32; + BITMAPINFO bitmap_info{}; + bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmap_info.bmiHeader.biWidth = size; + bitmap_info.bmiHeader.biHeight = -size; + bitmap_info.bmiHeader.biPlanes = 1; + bitmap_info.bmiHeader.biBitCount = 32; + bitmap_info.bmiHeader.biCompression = BI_RGB; + + void *pixels = nullptr; + HDC device = CreateCompatibleDC(nullptr); + HBITMAP bitmap = CreateDIBSection(device, &bitmap_info, DIB_RGB_COLORS, &pixels, nullptr, 0); + unsigned int texture = 0; + if (bitmap && pixels) + { + const HGDIOBJ previous = SelectObject(device, bitmap); + std::fill_n(static_cast(pixels), size * size * 4, 0); + if (DrawIconEx(device, 0, 0, icon, size, size, 0, nullptr, DI_NORMAL)) + { + auto *rgba = static_cast(pixels); + for (int pixel = 0; pixel < size * size; ++pixel) + std::swap(rgba[pixel * 4], rgba[pixel * 4 + 2]); + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, size, size, 0, + GL_RGBA, GL_UNSIGNED_BYTE, pixels); + glBindTexture(GL_TEXTURE_2D, 0); + } + SelectObject(device, previous); + } + if (bitmap) + DeleteObject(bitmap); + if (device) + DeleteDC(device); + if (large_icon) + DestroyIcon(large_icon); + if (small_icon) + DestroyIcon(small_icon); + return texture; +#else + (void)executable; + return 0; +#endif +} + +void ApplicationIconCache::shutdown() +{ + for (auto &[path, texture] : textures_) + { + (void)path; + if (texture) + glDeleteTextures(1, &texture); + } + textures_.clear(); +} diff --git a/src/managers/application_icon_cache.h b/src/managers/application_icon_cache.h new file mode 100644 index 0000000..a1e459b --- /dev/null +++ b/src/managers/application_icon_cache.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +class ApplicationIconCache +{ +public: + ~ApplicationIconCache(); + + ApplicationIconCache(const ApplicationIconCache &) = delete; + ApplicationIconCache &operator=(const ApplicationIconCache &) = delete; + ApplicationIconCache() = default; + + unsigned int textureFor(const std::filesystem::path &executable); + void shutdown(); + +private: + unsigned int loadTexture(const std::filesystem::path &executable) const; + std::map textures_; +}; diff --git a/src/managers/application_manager.cpp b/src/managers/application_manager.cpp index b9d6ba0..b0c304e 100644 --- a/src/managers/application_manager.cpp +++ b/src/managers/application_manager.cpp @@ -1,65 +1,76 @@ #include "managers/application_manager.h" +#include +#include #include #include -#include -namespace { -std::filesystem::path environmentPath(const char* name) { - const char* value = std::getenv(name); - return value && *value ? std::filesystem::path(value) : std::filesystem::path{}; -} - -std::filesystem::path firstExisting(std::initializer_list candidates) { - std::error_code error; - for (const auto& candidate : candidates) - if (!candidate.empty() && std::filesystem::is_regular_file(candidate, error)) return candidate; - return {}; -} - -std::filesystem::path findExecutable(const std::filesystem::path& root, const char* filename) { - std::error_code error; - if (root.empty() || !std::filesystem::is_directory(root, error)) return {}; - for (std::filesystem::recursive_directory_iterator iterator( - root, std::filesystem::directory_options::skip_permission_denied, error), end; - iterator != end && !error; iterator.increment(error)) { - if (iterator->is_regular_file(error) && iterator->path().filename() == filename) - return iterator->path(); +namespace +{ + std::filesystem::path environmentPath(const char *name) + { + const auto value = izo::GetEnvVar(name); + return value && !value->empty() ? std::filesystem::path(*value) : std::filesystem::path{}; + } + + std::filesystem::path firstExisting(std::initializer_list candidates) + { + std::error_code error; + for (const auto &candidate : candidates) + if (!candidate.empty() && std::filesystem::is_regular_file(candidate, error)) + return candidate; + return {}; + } + + std::filesystem::path findExecutable(const std::filesystem::path &root, const char *filename) + { + std::error_code error; + if (root.empty() || !std::filesystem::is_directory(root, error)) + return {}; + for (std::filesystem::recursive_directory_iterator iterator( + root, std::filesystem::directory_options::skip_permission_denied, error), + end; + iterator != end && !error; iterator.increment(error)) + { + if (iterator->is_regular_file(error) && iterator->path().filename() == filename) + return iterator->path(); + } + return {}; } - return {}; -} } -ApplicationManager::ApplicationManager() { - const auto local = environmentPath("LOCALAPPDATA"); +ApplicationManager::ApplicationManager() +{ + const auto local = izo::GetKnownPath(izo::KnownPath::LocalAppData); const auto program_files = environmentPath("ProgramFiles"); const auto program_files_x86 = environmentPath("ProgramFiles(x86)"); const auto windows = environmentPath("WINDIR"); - auto add = [this](ExternalApplicationId id, const char* name, std::filesystem::path executable, - std::vector arguments = {}) { + auto add = [this](ExternalApplicationId id, const char *name, std::filesystem::path executable, + std::vector arguments = {}) + { const bool available = !executable.empty(); - targets_.push_back({{id, name, available}, std::move(executable), std::move(arguments)}); + targets_.push_back({{id, name, available, std::move(executable)}, std::move(arguments)}); applications_.push_back(targets_.back().info); }; add(ExternalApplicationId::visual_studio_code, "VS Code", firstExisting({ - local / "Programs/Microsoft VS Code/Code.exe", - program_files / "Microsoft VS Code/Code.exe", - program_files_x86 / "Microsoft VS Code/Code.exe", - })); + local / "Programs/Microsoft VS Code/Code.exe", + program_files / "Microsoft VS Code/Code.exe", + program_files_x86 / "Microsoft VS Code/Code.exe", + })); add(ExternalApplicationId::visual_studio, "Visual Studio", firstExisting({ - program_files / "Microsoft Visual Studio/2022/Community/Common7/IDE/devenv.exe", - program_files / "Microsoft Visual Studio/2022/Professional/Common7/IDE/devenv.exe", - program_files / "Microsoft Visual Studio/2022/Enterprise/Common7/IDE/devenv.exe", - program_files_x86 / "Microsoft Visual Studio/2019/Community/Common7/IDE/devenv.exe", - })); + program_files / "Microsoft Visual Studio/2022/Community/Common7/IDE/devenv.exe", + program_files / "Microsoft Visual Studio/2022/Professional/Common7/IDE/devenv.exe", + program_files / "Microsoft Visual Studio/2022/Enterprise/Common7/IDE/devenv.exe", + program_files_x86 / "Microsoft Visual Studio/2019/Community/Common7/IDE/devenv.exe", + })); add(ExternalApplicationId::antigravity, "Antigravity", firstExisting({ - local / "Programs/Antigravity/Antigravity.exe", - local / "Antigravity/Antigravity.exe", - program_files / "Antigravity/Antigravity.exe", - })); + local / "Programs/Antigravity/Antigravity.exe", + local / "Antigravity/Antigravity.exe", + program_files / "Antigravity/Antigravity.exe", + })); std::filesystem::path github_desktop = firstExisting({ local / "GitHubDesktop/GitHubDesktop.exe", local / "GitHub Desktop/GitHubDesktop.exe", @@ -72,35 +83,51 @@ ApplicationManager::ApplicationManager() { add(ExternalApplicationId::terminal, "Terminal", firstExisting({local / "Microsoft/WindowsApps/wt.exe", windows / "System32/wt.exe"}), {"-d"}); add(ExternalApplicationId::git_bash, "Git Bash", firstExisting({ - program_files / "Git/git-bash.exe", program_files_x86 / "Git/git-bash.exe", - }), {"--cd="}); + program_files / "Git/git-bash.exe", + program_files_x86 / "Git/git-bash.exe", + }), + {"--cd="}); add(ExternalApplicationId::wsl, "WSL", firstExisting({windows / "System32/wsl.exe"}), {"--cd"}); add(ExternalApplicationId::android_studio, "Android Studio", firstExisting({ - program_files / "Android/Android Studio/bin/studio64.exe", - local / "Programs/Android Studio/bin/studio64.exe", - })); + program_files / "Android/Android Studio/bin/studio64.exe", + local / "Programs/Android Studio/bin/studio64.exe", + })); std::filesystem::path idea = firstExisting({ program_files / "JetBrains/IntelliJ IDEA 2025.1/bin/idea64.exe", program_files / "JetBrains/IntelliJ IDEA 2024.3/bin/idea64.exe", local / "Programs/IntelliJ IDEA Ultimate/bin/idea64.exe", }); - if (idea.empty()) idea = findExecutable(local / "JetBrains/Toolbox/apps", "idea64.exe"); + if (idea.empty()) + idea = findExecutable(local / "JetBrains/Toolbox/apps", "idea64.exe"); add(ExternalApplicationId::intellij_idea, "IntelliJ IDEA", std::move(idea)); } -ExternalApplicationId ApplicationManager::defaultApplication() const { +const ExternalApplication *ApplicationManager::application(ExternalApplicationId id) const +{ + const auto found = std::find_if(applications_.begin(), applications_.end(), + [id](const ExternalApplication &application) + { return application.id == id; }); + return found == applications_.end() ? nullptr : &*found; +} + +ExternalApplicationId ApplicationManager::defaultApplication() const +{ const auto available = std::find_if(targets_.begin(), targets_.end(), - [](const LaunchTarget& target) { return target.info.available; }); + [](const LaunchTarget &target) + { return target.info.available; }); return available == targets_.end() - ? ExternalApplicationId::file_explorer - : available->info.id; + ? ExternalApplicationId::file_explorer + : available->info.id; } bool ApplicationManager::launch(ExternalApplicationId application, - const std::filesystem::path& repository, std::string& error) const { + const std::filesystem::path &repository, std::string &error) const +{ const auto found = std::find_if(targets_.begin(), targets_.end(), - [application](const LaunchTarget& target) { return target.info.id == application; }); - if (found == targets_.end() || !found->info.available) { + [application](const LaunchTarget &target) + { return target.info.id == application; }); + if (found == targets_.end() || !found->info.available) + { error = "The selected application is not installed"; return false; } @@ -111,9 +138,13 @@ bool ApplicationManager::launch(ExternalApplicationId application, else arguments.push_back(repository_path); const izo::ProcessResult result = izo::LaunchProcess({ - found->executable, std::move(arguments), repository, true, + found->info.executable, + std::move(arguments), + repository, + true, }); - if (!result) { + if (!result) + { error = result.errorMessage.empty() ? "Unable to launch application" : result.errorMessage; return false; } diff --git a/src/managers/application_manager.h b/src/managers/application_manager.h index 00d40d4..5c8c9ef 100644 --- a/src/managers/application_manager.h +++ b/src/managers/application_manager.h @@ -4,7 +4,8 @@ #include #include -enum class ExternalApplicationId { +enum class ExternalApplicationId +{ visual_studio_code, visual_studio, antigravity, @@ -17,25 +18,29 @@ enum class ExternalApplicationId { intellij_idea, }; -struct ExternalApplication { +struct ExternalApplication +{ ExternalApplicationId id; std::string name; bool available = false; + std::filesystem::path executable; }; -class ApplicationManager { +class ApplicationManager +{ public: ApplicationManager(); - const std::vector& applications() const { return applications_; } + const std::vector &applications() const { return applications_; } + const ExternalApplication *application(ExternalApplicationId id) const; ExternalApplicationId defaultApplication() const; - bool launch(ExternalApplicationId application, const std::filesystem::path& repository, - std::string& error) const; + bool launch(ExternalApplicationId application, const std::filesystem::path &repository, + std::string &error) const; private: - struct LaunchTarget { + struct LaunchTarget + { ExternalApplication info; - std::filesystem::path executable; std::vector arguments; }; diff --git a/src/managers/avatar_cache.cpp b/src/managers/avatar_cache.cpp index 46d3652..782876e 100644 --- a/src/managers/avatar_cache.cpp +++ b/src/managers/avatar_cache.cpp @@ -17,23 +17,31 @@ #include #endif -AvatarCache::AvatarCache(const std::filesystem::path& user_data_directory) - : directory_(user_data_directory / "avatars") { +AvatarCache::AvatarCache(const std::filesystem::path &user_data_directory) + : directory_(user_data_directory / "avatars") +{ std::filesystem::create_directories(directory_); } -AvatarCache::~AvatarCache() { +AvatarCache::~AvatarCache() +{ shutdown(); } -std::string AvatarCache::hashEmail(const std::string& email) const { +std::string AvatarCache::hashEmail(const std::string &email) const +{ std::string normalized = email; normalized.erase(normalized.begin(), std::find_if(normalized.begin(), normalized.end(), - [](unsigned char value) { return !std::isspace(value); })); + [](unsigned char value) + { return !std::isspace(value); })); normalized.erase(std::find_if(normalized.rbegin(), normalized.rend(), - [](unsigned char value) { return !std::isspace(value); }).base(), normalized.end()); + [](unsigned char value) + { return !std::isspace(value); }) + .base(), + normalized.end()); std::transform(normalized.begin(), normalized.end(), normalized.begin(), - [](unsigned char value) { return static_cast(std::tolower(value)); }); + [](unsigned char value) + { return static_cast(std::tolower(value)); }); #ifdef _WIN32 BCRYPT_ALG_HANDLE algorithm = nullptr; @@ -43,99 +51,121 @@ std::string AvatarCache::hashEmail(const std::string& email) const { DWORD written = 0; if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_MD5_ALGORITHM, nullptr, 0) < 0 || BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&object_size), - sizeof(object_size), &written, 0) < 0) { - if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0); + sizeof(object_size), &written, 0) < 0) + { + if (algorithm) + BCryptCloseAlgorithmProvider(algorithm, 0); return {}; } std::vector object(object_size); if (BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) < 0 || BCryptHashData(hash, reinterpret_cast(normalized.data()), - static_cast(normalized.size()), 0) < 0 || - BCryptFinishHash(hash, digest.data(), static_cast(digest.size()), 0) < 0) { - if (hash) BCryptDestroyHash(hash); + static_cast(normalized.size()), 0) < 0 || + BCryptFinishHash(hash, digest.data(), static_cast(digest.size()), 0) < 0) + { + if (hash) + BCryptDestroyHash(hash); BCryptCloseAlgorithmProvider(algorithm, 0); return {}; } BCryptDestroyHash(hash); BCryptCloseAlgorithmProvider(algorithm, 0); std::ostringstream output; - for (unsigned char value : digest) output << std::hex << std::setw(2) << std::setfill('0') << static_cast(value); + for (unsigned char value : digest) + output << std::hex << std::setw(2) << std::setfill('0') << static_cast(value); return output.str(); #else return std::to_string(std::hash{}(normalized)); #endif } -unsigned int AvatarCache::textureFor(const std::string& email) { - if (email.empty()) return 0; +unsigned int AvatarCache::textureFor(const std::string &email) +{ + if (email.empty()) + return 0; const std::string hash = hashEmail(email); - if (hash.empty()) return 0; - Entry& entry = entries_[hash]; - if (entry.texture) return entry.texture; - if (entry.requested) return 0; + if (hash.empty()) + return 0; + Entry &entry = entries_[hash]; + if (entry.texture) + return entry.texture; + if (entry.requested) + return 0; entry.requested = true; entry.file = directory_ / (hash + ".img"); - if (std::filesystem::exists(entry.file)) { + if (std::filesystem::exists(entry.file)) + { entry.texture = loadTexture(entry.file); return entry.texture; } #ifdef _WIN32 const std::filesystem::path file = entry.file; const std::wstring url = L"https://www.gravatar.com/avatar/" + - std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g"; - entry.download = std::async(std::launch::async, [file, url] { - return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr)); - }); + std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g"; + entry.download = std::async(std::launch::async, [file, url] + { return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr)); }); #endif return 0; } -void AvatarCache::update() { - for (auto& [hash, entry] : entries_) { +void AvatarCache::update() +{ + for (auto &[hash, entry] : entries_) + { (void)hash; - if (entry.texture || !entry.download.valid()) continue; - if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready) continue; - if (entry.download.get()) entry.texture = loadTexture(entry.file); + if (entry.texture || !entry.download.valid()) + continue; + if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready) + continue; + if (entry.download.get()) + entry.texture = loadTexture(entry.file); } } -unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const { +unsigned int AvatarCache::loadTexture(const std::filesystem::path &file) const +{ #ifdef _WIN32 CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - IWICImagingFactory* factory = nullptr; - IWICBitmapDecoder* decoder = nullptr; - IWICBitmapFrameDecode* frame = nullptr; - IWICFormatConverter* converter = nullptr; + IWICImagingFactory *factory = nullptr; + IWICBitmapDecoder *decoder = nullptr; + IWICBitmapFrameDecode *frame = nullptr; + IWICFormatConverter *converter = nullptr; UINT width = 0; UINT height = 0; std::vector pixels; unsigned int texture = 0; if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, - IID_PPV_ARGS(&factory))) && + IID_PPV_ARGS(&factory))) && SUCCEEDED(factory->CreateDecoderFromFilename(file.wstring().c_str(), nullptr, GENERIC_READ, - WICDecodeMetadataCacheOnLoad, &decoder)) && + WICDecodeMetadataCacheOnLoad, &decoder)) && SUCCEEDED(decoder->GetFrame(0, &frame)) && SUCCEEDED(factory->CreateFormatConverter(&converter)) && SUCCEEDED(converter->Initialize(frame, GUID_WICPixelFormat32bppRGBA, - WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) && - SUCCEEDED(converter->GetSize(&width, &height))) { + WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) && + SUCCEEDED(converter->GetSize(&width, &height))) + { pixels.resize(static_cast(width) * height * 4); if (SUCCEEDED(converter->CopyPixels(nullptr, width * 4, - static_cast(pixels.size()), pixels.data()))) { + static_cast(pixels.size()), pixels.data()))) + { glGenTextures(1, &texture); glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, static_cast(width), static_cast(height), - 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); glBindTexture(GL_TEXTURE_2D, 0); } } - if (converter) converter->Release(); - if (frame) frame->Release(); - if (decoder) decoder->Release(); - if (factory) factory->Release(); + if (converter) + converter->Release(); + if (frame) + frame->Release(); + if (decoder) + decoder->Release(); + if (factory) + factory->Release(); CoUninitialize(); return texture; #else @@ -144,11 +174,15 @@ unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const { #endif } -void AvatarCache::shutdown() { - for (auto& [hash, entry] : entries_) { +void AvatarCache::shutdown() +{ + for (auto &[hash, entry] : entries_) + { (void)hash; - if (entry.download.valid()) entry.download.wait(); - if (entry.texture) glDeleteTextures(1, &entry.texture); + if (entry.download.valid()) + entry.download.wait(); + if (entry.texture) + glDeleteTextures(1, &entry.texture); entry.texture = 0; } entries_.clear(); diff --git a/src/managers/avatar_cache.h b/src/managers/avatar_cache.h index c911221..15ffa55 100644 --- a/src/managers/avatar_cache.h +++ b/src/managers/avatar_cache.h @@ -5,28 +5,30 @@ #include #include -class AvatarCache { +class AvatarCache +{ public: - explicit AvatarCache(const std::filesystem::path& user_data_directory); + explicit AvatarCache(const std::filesystem::path &user_data_directory); ~AvatarCache(); - AvatarCache(const AvatarCache&) = delete; - AvatarCache& operator=(const AvatarCache&) = delete; + AvatarCache(const AvatarCache &) = delete; + AvatarCache &operator=(const AvatarCache &) = delete; - unsigned int textureFor(const std::string& email); + unsigned int textureFor(const std::string &email); void update(); void shutdown(); private: - struct Entry { + struct Entry + { unsigned int texture = 0; bool requested = false; std::filesystem::path file; std::future download; }; - std::string hashEmail(const std::string& email) const; - unsigned int loadTexture(const std::filesystem::path& file) const; + std::string hashEmail(const std::string &email) const; + unsigned int loadTexture(const std::filesystem::path &file) const; std::filesystem::path directory_; std::map entries_; }; diff --git a/src/managers/git_manager.cpp b/src/managers/git_manager.cpp index 187d7c4..00b8a62 100644 --- a/src/managers/git_manager.cpp +++ b/src/managers/git_manager.cpp @@ -1,5 +1,8 @@ #include "managers/git_manager.h" +#include +#include + #include #include #include @@ -11,93 +14,147 @@ #include #endif -namespace { -void addBadge(RepositoryView& repository, const git_oid* oid, RefBadge badge) { - if (!oid) return; - const auto commit = std::find_if(repository.commits.begin(), repository.commits.end(), - [oid](const CommitInfo& item) { return git_oid_equal(&item.oid, oid) != 0; }); - if (commit != repository.commits.end()) commit->refs.push_back(std::move(badge)); -} - -bool remoteIsCoveredByLocalUpstream(const RepositoryView& repository, const git_oid* oid, - const std::string& remote_name) { - if (!oid) return false; - const size_t slash = remote_name.find('/'); - const std::string local_name = slash == std::string::npos - ? remote_name - : remote_name.substr(slash + 1); - const auto commit = std::find_if(repository.commits.begin(), repository.commits.end(), - [oid](const CommitInfo& item) { return git_oid_equal(&item.oid, oid) != 0; }); - if (commit == repository.commits.end()) return false; - return std::any_of(commit->refs.begin(), commit->refs.end(), [&local_name](const RefBadge& badge) { - return badge.kind == RefKind::local && badge.upstream && badge.name == local_name; - }); -} - -int submoduleCallback(git_submodule* submodule, const char*, void* payload) { - static_cast*>(payload)->emplace_back(git_submodule_name(submodule)); - return 0; -} - -int diffFileCallback(const git_diff_delta* delta, float, void* payload) { - auto* files = static_cast*>(payload); - ChangedFile file; - const char* path = delta->new_file.path ? delta->new_file.path : delta->old_file.path; - file.path = path ? path : "(unknown)"; - switch (delta->status) { - case GIT_DELTA_ADDED: file.kind = FileChangeKind::added; break; - case GIT_DELTA_MODIFIED: file.kind = FileChangeKind::modified; break; - case GIT_DELTA_DELETED: file.kind = FileChangeKind::deleted; break; - case GIT_DELTA_RENAMED: file.kind = FileChangeKind::renamed; break; - default: file.kind = FileChangeKind::other; break; +namespace +{ + void addBadge(RepositoryView &repository, const git_oid *oid, RefBadge badge) + { + if (!oid) + return; + const auto commit = std::find_if(repository.commits.begin(), repository.commits.end(), + [oid](const CommitInfo &item) + { return git_oid_equal(&item.oid, oid) != 0; }); + if (commit != repository.commits.end()) + commit->refs.push_back(std::move(badge)); + } + + bool remoteIsCoveredByLocalUpstream(const RepositoryView &repository, const git_oid *oid, + const std::string &remote_name) + { + if (!oid) + return false; + const size_t slash = remote_name.find('/'); + const std::string local_name = slash == std::string::npos + ? remote_name + : remote_name.substr(slash + 1); + const auto commit = std::find_if(repository.commits.begin(), repository.commits.end(), + [oid](const CommitInfo &item) + { return git_oid_equal(&item.oid, oid) != 0; }); + if (commit == repository.commits.end()) + return false; + return std::any_of(commit->refs.begin(), commit->refs.end(), [&local_name](const RefBadge &badge) + { return badge.kind == RefKind::local && badge.upstream && badge.name == local_name; }); + } + + int submoduleCallback(git_submodule *submodule, const char *, void *payload) + { + auto *repository = static_cast(payload); + const char *name = git_submodule_name(submodule); + if (!name) + return 0; + repository->submodules.emplace_back(name); + unsigned int status = 0; + if (git_submodule_status(&status, repository->repo, name, + GIT_SUBMODULE_IGNORE_UNSPECIFIED) == 0) + repository->submodule_statuses[name] = status; + return 0; + } + + int diffFileCallback(const git_diff_delta *delta, float, void *payload) + { + auto *files = static_cast *>(payload); + ChangedFile file; + const char *path = delta->new_file.path ? delta->new_file.path : delta->old_file.path; + file.path = path ? path : "(unknown)"; + switch (delta->status) + { + case GIT_DELTA_ADDED: + file.kind = FileChangeKind::added; + break; + case GIT_DELTA_MODIFIED: + file.kind = FileChangeKind::modified; + break; + case GIT_DELTA_DELETED: + file.kind = FileChangeKind::deleted; + break; + case GIT_DELTA_RENAMED: + file.kind = FileChangeKind::renamed; + break; + default: + file.kind = FileChangeKind::other; + break; + } + files->push_back(std::move(file)); + return 0; + } + + int treeFileCallback(const char *root, const git_tree_entry *entry, void *payload) + { + const git_object_t type = git_tree_entry_type(entry); + if (type != GIT_OBJECT_BLOB && type != GIT_OBJECT_COMMIT) + return 0; + auto *files = static_cast *>(payload); + files->push_back({std::string(root ? root : "") + git_tree_entry_name(entry), + FileChangeKind::other}); + return 0; } - files->push_back(std::move(file)); - return 0; -} #ifdef _WIN32 -std::wstring utf8ToWide(const std::string& value) { - if (value.empty()) return {}; - const int length = MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, nullptr, 0); - std::wstring result(static_cast(std::max(0, length)), L'\0'); - if (length > 1) MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, result.data(), length); - if (!result.empty()) result.pop_back(); - return result; -} - -std::wstring quoteWindowsArgument(const std::wstring& value) { - if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) return value; - std::wstring result = L"\""; - size_t slashes = 0; - for (const wchar_t character : value) { - if (character == L'\\') { - ++slashes; - } else if (character == L'\"') { - result.append(slashes * 2 + 1, L'\\'); - result.push_back(L'\"'); - slashes = 0; - } else { - result.append(slashes, L'\\'); - slashes = 0; - result.push_back(character); - } + std::wstring utf8ToWide(const std::string &value) + { + if (value.empty()) + return {}; + const int length = MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, nullptr, 0); + std::wstring result(static_cast(std::max(0, length)), L'\0'); + if (length > 1) + MultiByteToWideChar(CP_UTF8, 0, value.c_str(), -1, result.data(), length); + if (!result.empty()) + result.pop_back(); + return result; + } + + std::wstring quoteWindowsArgument(const std::wstring &value) + { + if (value.find_first_of(L" \t\n\v\"") == std::wstring::npos) + return value; + std::wstring result = L"\""; + size_t slashes = 0; + for (const wchar_t character : value) + { + if (character == L'\\') + { + ++slashes; + } + else if (character == L'\"') + { + result.append(slashes * 2 + 1, L'\\'); + result.push_back(L'\"'); + slashes = 0; + } + else + { + result.append(slashes, L'\\'); + slashes = 0; + result.push_back(character); + } + } + result.append(slashes * 2, L'\\'); + result.push_back(L'\"'); + return result; } - result.append(slashes * 2, L'\\'); - result.push_back(L'\"'); - return result; -} #endif } GitManager::GitManager() { git_libgit2_init(); } GitManager::~GitManager() { git_libgit2_shutdown(); } -std::string GitManager::lastError(const char* fallback) { - const git_error* error = git_error_last(); +std::string GitManager::lastError(const char *fallback) +{ + const git_error *error = git_error_last(); return error && error->message ? error->message : fallback; } -std::string GitManager::formatTime(git_time_t value, int offset_minutes) { +std::string GitManager::formatTime(git_time_t value, int offset_minutes) +{ std::time_t adjusted = static_cast(value + offset_minutes * 60); std::tm tm{}; #ifdef _WIN32 @@ -110,32 +167,75 @@ std::string GitManager::formatTime(git_time_t value, int offset_minutes) { return buffer; } -void GitManager::readBranches(RepositoryView& repository, git_branch_t type, - std::vector& output) { - git_branch_iterator* iterator = nullptr; - if (git_branch_iterator_new(&iterator, repository.repo, type) != 0) return; - git_reference* reference = nullptr; +void GitManager::readBranches(RepositoryView &repository, git_branch_t type, + std::vector &output) +{ + git_branch_iterator *iterator = nullptr; + if (git_branch_iterator_new(&iterator, repository.repo, type) != 0) + return; + git_reference *reference = nullptr; git_branch_t found{}; - while (git_branch_next(&reference, &found, iterator) == 0) { - const char* name = nullptr; - if (git_branch_name(&name, reference) == 0 && name) output.emplace_back(name); + while (git_branch_next(&reference, &found, iterator) == 0) + { + const char *name = nullptr; + if (git_branch_name(&name, reference) == 0 && name) + output.emplace_back(name); git_reference_free(reference); } git_branch_iterator_free(iterator); } -void GitManager::loadRefBadges(RepositoryView& repository) { - for (CommitInfo& commit : repository.commits) commit.refs.clear(); - for (const git_branch_t type : {GIT_BRANCH_LOCAL, GIT_BRANCH_REMOTE}) { - git_branch_iterator* iterator = nullptr; - if (git_branch_iterator_new(&iterator, repository.repo, type) != 0) continue; - git_reference* reference = nullptr; +void GitManager::loadBranchDivergence(RepositoryView &repository) +{ + repository.local_branch_divergence.clear(); + repository.remote_branch_divergence.clear(); + for (const std::string &branch_name : repository.local_branches) + { + git_reference *local = nullptr; + git_reference *upstream = nullptr; + if (git_branch_lookup(&local, repository.repo, branch_name.c_str(), GIT_BRANCH_LOCAL) != 0 || + git_branch_upstream(&upstream, local) != 0) + { + git_reference_free(upstream); + git_reference_free(local); + continue; + } + const git_oid *local_oid = git_reference_target(local); + const git_oid *upstream_oid = git_reference_target(upstream); + size_t ahead = 0; + size_t behind = 0; + if (local_oid && upstream_oid && + git_graph_ahead_behind(&ahead, &behind, repository.repo, local_oid, upstream_oid) == 0) + { + repository.local_branch_divergence[branch_name] = {ahead, behind}; + const char *remote_name = nullptr; + if (git_branch_name(&remote_name, upstream) == 0 && remote_name) + repository.remote_branch_divergence[remote_name] = {behind, ahead}; + } + git_reference_free(upstream); + git_reference_free(local); + } +} + +void GitManager::loadRefBadges(RepositoryView &repository) +{ + for (CommitInfo &commit : repository.commits) + commit.refs.clear(); + for (const git_branch_t type : {GIT_BRANCH_LOCAL, GIT_BRANCH_REMOTE}) + { + git_branch_iterator *iterator = nullptr; + if (git_branch_iterator_new(&iterator, repository.repo, type) != 0) + continue; + git_reference *reference = nullptr; git_branch_t found{}; - while (git_branch_next(&reference, &found, iterator) == 0) { - const char* name = nullptr; - if (git_branch_name(&name, reference) == 0 && name) { + while (git_branch_next(&reference, &found, iterator) == 0) + { + const char *name = nullptr; + if (git_branch_name(&name, reference) == 0 && name) + { const std::string branch_name = name; - if (type == GIT_BRANCH_REMOTE && branch_name.ends_with("/HEAD")) { + if (type == GIT_BRANCH_REMOTE && branch_name.ends_with("/HEAD")) + { git_reference_free(reference); continue; } @@ -144,17 +244,27 @@ void GitManager::loadRefBadges(RepositoryView& repository) { badge.kind = type == GIT_BRANCH_LOCAL ? RefKind::local : RefKind::remote; badge.current = type == GIT_BRANCH_LOCAL && badge.name == repository.branch; badge.worktree = type == GIT_BRANCH_LOCAL && - (badge.current || repository.worktree_branches.contains(badge.name)); - if (type == GIT_BRANCH_LOCAL) { - git_reference* upstream = nullptr; - badge.upstream = git_branch_upstream(&upstream, reference) == 0; + (badge.current || repository.worktree_branches.contains(badge.name)); + if (type == GIT_BRANCH_LOCAL) + { + git_reference *upstream = nullptr; + if (git_branch_upstream(&upstream, reference) == 0) + { + const git_oid *local_oid = git_reference_target(reference); + const git_oid *upstream_oid = git_reference_target(upstream); + // Collapse the cloud into the local badge only while both refs + // identify the same commit. A diverged remote gets its own badge. + badge.upstream = local_oid && upstream_oid && + git_oid_equal(local_oid, upstream_oid) != 0; + } git_reference_free(upstream); } - git_object* object = nullptr; - if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) { - const git_oid* oid = git_object_id(object); + git_object *object = nullptr; + if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) + { + const git_oid *oid = git_object_id(object); if (type != GIT_BRANCH_REMOTE || - !remoteIsCoveredByLocalUpstream(repository, oid, branch_name)) + !remoteIsCoveredByLocalUpstream(repository, oid, branch_name)) addBadge(repository, oid, std::move(badge)); git_object_free(object); } @@ -164,13 +274,16 @@ void GitManager::loadRefBadges(RepositoryView& repository) { git_branch_iterator_free(iterator); } - git_reference_iterator* tags = nullptr; - if (git_reference_iterator_glob_new(&tags, repository.repo, "refs/tags/*") != 0) return; - git_reference* reference = nullptr; - while (git_reference_next(&reference, tags) == 0) { - git_object* object = nullptr; - if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) { - const char* name = git_reference_shorthand(reference); + git_reference_iterator *tags = nullptr; + if (git_reference_iterator_glob_new(&tags, repository.repo, "refs/tags/*") != 0) + return; + git_reference *reference = nullptr; + while (git_reference_next(&reference, tags) == 0) + { + git_object *object = nullptr; + if (git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0) + { + const char *name = git_reference_shorthand(reference); addBadge(repository, git_object_id(object), {name ? name : "tag", RefKind::tag}); git_object_free(object); } @@ -179,101 +292,172 @@ void GitManager::loadRefBadges(RepositoryView& repository) { git_reference_iterator_free(tags); } -void GitManager::computeGraphLanes(RepositoryView& repository) { - std::vector lanes; - for (auto& commit : repository.commits) { - auto current = std::find_if(lanes.begin(), lanes.end(), [&commit](const git_oid& oid) { - return git_oid_equal(&oid, &commit.oid) != 0; - }); - if (current == lanes.end()) { lanes.push_back(commit.oid); current = std::prev(lanes.end()); } - const size_t lane = static_cast(std::distance(lanes.begin(), current)); - commit.lane = static_cast(lane); - for (size_t duplicate = lanes.size(); duplicate-- > lane + 1;) { - if (git_oid_equal(&lanes[duplicate], &commit.oid) != 0) - lanes.erase(lanes.begin() + static_cast(duplicate)); +void GitManager::computeGraphLanes(RepositoryView &repository) +{ + struct Lane + { + git_oid expected{}; + int color = 0; + }; + std::vector lanes; + int next_color = 0; + for (auto &commit : repository.commits) + { + auto current = std::find_if(lanes.begin(), lanes.end(), [&commit](const Lane &lane) + { return git_oid_equal(&lane.expected, &commit.oid) != 0; }); + if (current == lanes.end()) + { + lanes.push_back({commit.oid, next_color++}); + current = std::prev(lanes.end()); } - if (commit.parent_ids.empty()) { lanes.erase(lanes.begin() + static_cast(lane)); continue; } - lanes[lane] = commit.parent_ids.front(); - for (size_t parent = 1; parent < commit.parent_ids.size(); ++parent) { - const auto found = std::find_if(lanes.begin(), lanes.end(), [&commit, parent](const git_oid& oid) { - return git_oid_equal(&oid, &commit.parent_ids[parent]) != 0; - }); + const size_t commit_lane = static_cast(std::distance(lanes.begin(), current)); + size_t active_lane = commit_lane; + commit.lane = static_cast(commit_lane); + commit.graph_color = current->color; + for (size_t duplicate = lanes.size(); duplicate-- > 0;) + { + if (duplicate != active_lane && + git_oid_equal(&lanes[duplicate].expected, &commit.oid) != 0) + { + lanes.erase(lanes.begin() + static_cast(duplicate)); + if (duplicate < active_lane) + --active_lane; + } + } + if (commit.parent_ids.empty()) + { + lanes.erase(lanes.begin() + static_cast(active_lane)); + continue; + } + lanes[active_lane].expected = commit.parent_ids.front(); + for (size_t duplicate = lanes.size(); duplicate-- > 0;) + { + if (duplicate != active_lane && + git_oid_equal(&lanes[duplicate].expected, &commit.parent_ids.front()) != 0) + { + lanes.erase(lanes.begin() + static_cast(duplicate)); + if (duplicate < active_lane) + --active_lane; + } + } + for (size_t parent = 1; parent < commit.parent_ids.size(); ++parent) + { + const auto found = std::find_if(lanes.begin(), lanes.end(), [&commit, parent](const Lane &candidate) + { return git_oid_equal(&candidate.expected, &commit.parent_ids[parent]) != 0; }); if (found == lanes.end()) - lanes.insert(lanes.begin() + static_cast(lane + parent), commit.parent_ids[parent]); + lanes.insert(lanes.begin() + static_cast( + std::min(active_lane + parent, lanes.size())), + {commit.parent_ids[parent], next_color++}); } } } -void GitManager::loadWorkingTree(RepositoryView& repository) { +void GitManager::loadWorkingTree(RepositoryView &repository) +{ repository.working_files.clear(); git_status_options options{}; git_status_options_init(&options, GIT_STATUS_OPTIONS_VERSION); options.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; options.flags = GIT_STATUS_OPT_INCLUDE_UNTRACKED | GIT_STATUS_OPT_RECURSE_UNTRACKED_DIRS | - GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | GIT_STATUS_OPT_RENAMES_INDEX_TO_WORKDIR; - git_status_list* list = nullptr; - if (git_status_list_new(&list, repository.repo, &options) != 0) return; + GIT_STATUS_OPT_RENAMES_HEAD_TO_INDEX | GIT_STATUS_OPT_RENAMES_INDEX_TO_WORKDIR; + git_status_list *list = nullptr; + if (git_status_list_new(&list, repository.repo, &options) != 0) + return; - auto add_file = [&repository](const char* path, FileChangeKind kind, bool staged) { - if (path) repository.working_files.push_back({path, kind, staged}); + auto add_file = [&repository](const char *path, FileChangeKind kind, bool staged) + { + if (path) + repository.working_files.push_back({path, kind, staged}); }; - for (size_t index = 0; index < git_status_list_entrycount(list); ++index) { - const git_status_entry* entry = git_status_byindex(list, index); - if (!entry) continue; - const char* index_path = entry->head_to_index && entry->head_to_index->new_file.path - ? entry->head_to_index->new_file.path : nullptr; - const char* worktree_path = entry->index_to_workdir && entry->index_to_workdir->new_file.path - ? entry->index_to_workdir->new_file.path : nullptr; - if (entry->status & GIT_STATUS_INDEX_NEW) add_file(index_path, FileChangeKind::added, true); - else if (entry->status & GIT_STATUS_INDEX_DELETED) add_file(index_path, FileChangeKind::deleted, true); - else if (entry->status & GIT_STATUS_INDEX_RENAMED) add_file(index_path, FileChangeKind::renamed, true); - else if (entry->status & GIT_STATUS_INDEX_MODIFIED) add_file(index_path, FileChangeKind::modified, true); + for (size_t index = 0; index < git_status_list_entrycount(list); ++index) + { + const git_status_entry *entry = git_status_byindex(list, index); + if (!entry) + continue; + const char *index_path = entry->head_to_index && entry->head_to_index->new_file.path + ? entry->head_to_index->new_file.path + : nullptr; + const char *worktree_path = entry->index_to_workdir && entry->index_to_workdir->new_file.path + ? entry->index_to_workdir->new_file.path + : nullptr; + if (entry->status & GIT_STATUS_INDEX_NEW) + add_file(index_path, FileChangeKind::added, true); + else if (entry->status & GIT_STATUS_INDEX_DELETED) + add_file(index_path, FileChangeKind::deleted, true); + else if (entry->status & GIT_STATUS_INDEX_RENAMED) + add_file(index_path, FileChangeKind::renamed, true); + else if (entry->status & GIT_STATUS_INDEX_MODIFIED) + add_file(index_path, FileChangeKind::modified, true); - if (entry->status & GIT_STATUS_WT_NEW) add_file(worktree_path, FileChangeKind::added, false); - else if (entry->status & GIT_STATUS_WT_DELETED) add_file(worktree_path, FileChangeKind::deleted, false); - else if (entry->status & GIT_STATUS_WT_RENAMED) add_file(worktree_path, FileChangeKind::renamed, false); - else if (entry->status & GIT_STATUS_WT_MODIFIED) add_file(worktree_path, FileChangeKind::modified, false); + if (entry->status & GIT_STATUS_WT_NEW) + add_file(worktree_path, FileChangeKind::added, false); + else if (entry->status & GIT_STATUS_WT_DELETED) + add_file(worktree_path, FileChangeKind::deleted, false); + else if (entry->status & GIT_STATUS_WT_RENAMED) + add_file(worktree_path, FileChangeKind::renamed, false); + else if (entry->status & GIT_STATUS_WT_MODIFIED) + add_file(worktree_path, FileChangeKind::modified, false); } git_status_list_free(list); } -bool GitManager::loadRepositoryData(RepositoryView& repository, std::string& error) { - repository.local_branches.clear(); repository.remote_branches.clear(); repository.remotes.clear(); - repository.worktrees.clear(); repository.worktree_branches.clear(); repository.submodules.clear(); - repository.commits.clear(); repository.selected_commit = 0; - if (repository.commit_walk) git_revwalk_free(repository.commit_walk); +bool GitManager::loadRepositoryData(RepositoryView &repository, std::string &error) +{ + repository.local_branches.clear(); + repository.remote_branches.clear(); + repository.local_branch_divergence.clear(); + repository.remote_branch_divergence.clear(); + repository.remotes.clear(); + repository.worktrees.clear(); + repository.worktree_branches.clear(); + repository.submodules.clear(); + repository.submodule_statuses.clear(); + repository.commits.clear(); + repository.selected_commit = 0; + repository.scroll_to_commit = -1; + if (repository.commit_walk) + git_revwalk_free(repository.commit_walk); repository.commit_walk = nullptr; repository.history_exhausted = false; loadWorkingTree(repository); - const char* workdir = git_repository_workdir(repository.repo); + const char *workdir = git_repository_workdir(repository.repo); repository.path = workdir ? workdir : git_repository_path(repository.repo); std::filesystem::path path(repository.path); repository.name = path.filename().string(); - if (repository.name.empty()) repository.name = path.parent_path().filename().string(); + if (repository.name.empty()) + repository.name = path.parent_path().filename().string(); - git_reference* head = nullptr; - if (git_repository_head(&head, repository.repo) == 0) { - if (const char* name = git_reference_shorthand(head)) repository.branch = name; + git_reference *head = nullptr; + if (git_repository_head(&head, repository.repo) == 0) + { + if (const char *name = git_reference_shorthand(head)) + repository.branch = name; git_reference_free(head); } readBranches(repository, GIT_BRANCH_LOCAL, repository.local_branches); readBranches(repository, GIT_BRANCH_REMOTE, repository.remote_branches); + loadBranchDivergence(repository); git_strarray names{}; - if (git_remote_list(&names, repository.repo) == 0) { - for (size_t i = 0; i < names.count; ++i) repository.remotes.emplace_back(names.strings[i]); + if (git_remote_list(&names, repository.repo) == 0) + { + for (size_t i = 0; i < names.count; ++i) + repository.remotes.emplace_back(names.strings[i]); git_strarray_dispose(&names); } - if (git_worktree_list(&names, repository.repo) == 0) { - for (size_t i = 0; i < names.count; ++i) { + if (git_worktree_list(&names, repository.repo) == 0) + { + for (size_t i = 0; i < names.count; ++i) + { repository.worktrees.emplace_back(names.strings[i]); - git_worktree* worktree = nullptr; - git_repository* worktree_repository = nullptr; - git_reference* worktree_head = nullptr; + git_worktree *worktree = nullptr; + git_repository *worktree_repository = nullptr; + git_reference *worktree_head = nullptr; if (git_worktree_lookup(&worktree, repository.repo, names.strings[i]) == 0 && git_repository_open_from_worktree(&worktree_repository, worktree) == 0 && - git_repository_head(&worktree_head, worktree_repository) == 0) { - if (const char* branch = git_reference_shorthand(worktree_head)) + git_repository_head(&worktree_head, worktree_repository) == 0) + { + if (const char *branch = git_reference_shorthand(worktree_head)) repository.worktree_branches.emplace(branch); } git_reference_free(worktree_head); @@ -282,14 +466,23 @@ bool GitManager::loadRepositoryData(RepositoryView& repository, std::string& err } git_strarray_dispose(&names); } - git_submodule_foreach(repository.repo, submoduleCallback, &repository.submodules); + git_submodule_foreach(repository.repo, submoduleCallback, &repository); - if (git_revwalk_new(&repository.commit_walk, repository.repo) != 0) { + if (git_revwalk_new(&repository.commit_walk, repository.repo) != 0) + { error = lastError("Unable to read history"); return false; } git_revwalk_sorting(repository.commit_walk, GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME); - if (git_revwalk_push_head(repository.commit_walk) != 0) { + bool has_history = false; + for (const char *glob : {"refs/heads/*", "refs/remotes/*", "refs/tags/*"}) + { + const int push_result = git_revwalk_push_glob(repository.commit_walk, glob); + if (push_result == 0) + has_history = true; + } + if (!has_history) + { git_revwalk_free(repository.commit_walk); repository.commit_walk = nullptr; repository.history_exhausted = true; @@ -298,35 +491,64 @@ bool GitManager::loadRepositoryData(RepositoryView& repository, std::string& err return loadMoreCommits(repository, 500, error); } -bool GitManager::loadMoreCommits(RepositoryView& repository, size_t page_size, std::string& error) { - if (!repository.commit_walk || repository.history_exhausted || page_size == 0) return true; +bool GitManager::loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error) +{ + if (!repository.commit_walk || repository.history_exhausted || page_size == 0) + return true; size_t loaded = 0; git_oid oid{}; - while (loaded < page_size) { + while (loaded < page_size) + { const int walk_result = git_revwalk_next(&oid, repository.commit_walk); - if (walk_result == GIT_ITEROVER) { + if (walk_result == GIT_ITEROVER) + { repository.history_exhausted = true; git_revwalk_free(repository.commit_walk); repository.commit_walk = nullptr; break; } - if (walk_result != 0) { + if (walk_result != 0) + { error = lastError("Unable to continue reading history"); return false; } - git_commit* commit = nullptr; - if (git_commit_lookup(&commit, repository.repo, &oid) != 0) continue; - CommitInfo item; item.oid = oid; - char id[GIT_OID_SHA1_HEXSIZE + 1]{}; git_oid_tostr(id, sizeof(id), &oid); item.short_id.assign(id, 8); + git_commit *commit = nullptr; + if (git_commit_lookup(&commit, repository.repo, &oid) != 0) + continue; + CommitInfo item; + item.oid = oid; + char id[GIT_OID_SHA1_HEXSIZE + 1]{}; + git_oid_tostr(id, sizeof(id), &oid); + item.short_id.assign(id, 8); item.summary = git_commit_summary(commit) ? git_commit_summary(commit) : "(no commit message)"; - if (const git_signature* author = git_commit_author(commit)) { + if (const char *body = git_commit_body(commit)) + { + bool spacing = false; + for (const unsigned char character : std::string_view(body)) + { + if (std::isspace(character)) + { + spacing = !item.description.empty(); + } + else + { + if (spacing) + item.description.push_back(' '); + item.description.push_back(static_cast(character)); + spacing = false; + } + } + } + if (const git_signature *author = git_commit_author(commit)) + { item.author = author->name ? author->name : "Unknown"; item.email = author->email ? author->email : ""; item.date = formatTime(author->when.time, author->when.offset); } item.parents = static_cast(git_commit_parentcount(commit)); for (size_t i = 0; i < git_commit_parentcount(commit); ++i) - if (const git_oid* parent = git_commit_parent_id(commit, static_cast(i))) item.parent_ids.push_back(*parent); + if (const git_oid *parent = git_commit_parent_id(commit, static_cast(i))) + item.parent_ids.push_back(*parent); repository.commits.push_back(std::move(item)); git_commit_free(commit); ++loaded; @@ -336,31 +558,47 @@ bool GitManager::loadMoreCommits(RepositoryView& repository, size_t page_size, s return true; } -bool GitManager::openRepository(RepositoryView& repository, const std::string& path, std::string& error) { - git_repository* opened = nullptr; - if (git_repository_open_ext(&opened, path.c_str(), 0, nullptr) != 0) { error = lastError("Unable to open repository"); return false; } - repository.close(); repository.repo = opened; +bool GitManager::openRepository(RepositoryView &repository, const std::string &path, std::string &error) +{ + git_repository *opened = nullptr; + if (git_repository_open_ext(&opened, path.c_str(), 0, nullptr) != 0) + { + error = lastError("Unable to open repository"); + return false; + } + repository.close(); + repository.repo = opened; return loadRepositoryData(repository, error); } -bool GitManager::initializeRepository(RepositoryView& repository, const std::string& path, std::string& error) { - git_repository* created = nullptr; - if (git_repository_init(&created, path.c_str(), 0) != 0) { error = lastError("Unable to initialize repository"); return false; } +bool GitManager::initializeRepository(RepositoryView &repository, const std::string &path, std::string &error) +{ + git_repository *created = nullptr; + if (git_repository_init(&created, path.c_str(), 0) != 0) + { + error = lastError("Unable to initialize repository"); + return false; + } git_repository_free(created); return openRepository(repository, path, error); } -bool GitManager::reload(RepositoryView& repository, std::string& error) { +bool GitManager::reload(RepositoryView &repository, std::string &error) +{ return repository.repo && loadRepositoryData(repository, error); } -bool GitManager::runGit(RepositoryView& repository, const std::vector& arguments, - const char* success_message, std::string& message, bool reload_repository) { +bool GitManager::runGit(RepositoryView &repository, const std::vector &arguments, + const char *success_message, std::string &message, bool reload_repository) +{ std::string command_output; - if (!captureGit(repository, arguments, command_output, message)) return false; - if (reload_repository) { + if (!captureGit(repository, arguments, command_output, message)) + return false; + if (reload_repository) + { std::string reload_error; - if (!loadRepositoryData(repository, reload_error)) { + if (!loadRepositoryData(repository, reload_error)) + { message = reload_error; return false; } @@ -369,28 +607,35 @@ bool GitManager::runGit(RepositoryView& repository, const std::vector& arguments, - std::string& command_output, std::string& error) { - if (!repository.repo) { error = "No repository is open"; return false; } +bool GitManager::captureGit(RepositoryView &repository, const std::vector &arguments, + std::string &command_output, std::string &error) +{ + if (!repository.repo) + { + error = "No repository is open"; + return false; + } #ifdef _WIN32 wchar_t temporary_directory[MAX_PATH]{}; wchar_t output_path[MAX_PATH]{}; if (!GetTempPathW(MAX_PATH, temporary_directory) || - !GetTempFileNameW(temporary_directory, L"gtr", 0, output_path)) { + !GetTempFileNameW(temporary_directory, L"gtr", 0, output_path)) + { error = "Unable to create Git command output file"; return false; } SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; HANDLE output = CreateFileW(output_path, GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr); - if (output == INVALID_HANDLE_VALUE) { + FILE_SHARE_READ | FILE_SHARE_WRITE, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr); + if (output == INVALID_HANDLE_VALUE) + { DeleteFileW(output_path); error = "Unable to capture Git command output"; return false; } std::wstring command = L"git -C " + quoteWindowsArgument(utf8ToWide(repository.path)); - for (const std::string& argument : arguments) + for (const std::string &argument : arguments) command += L" " + quoteWindowsArgument(utf8ToWide(argument)); std::vector mutable_command(command.begin(), command.end()); mutable_command.push_back(L'\0'); @@ -403,9 +648,10 @@ bool GitManager::captureGit(RepositoryView& repository, const std::vector(patch.size())); } std::vector arguments{"apply", "--whitespace=nowarn"}; - if (cached) arguments.push_back("--cached"); - if (reverse) arguments.push_back("--reverse"); + if (cached) + arguments.push_back("--cached"); + if (reverse) + arguments.push_back("--reverse"); arguments.push_back(patch_path.string()); const bool applied = runGit(repository, arguments, - reverse ? "Hunk reverted" : "Hunk staged", error); + reverse ? "Hunk reverted" : "Hunk staged", error); std::error_code remove_error; std::filesystem::remove(patch_path, remove_error); return applied; } -bool GitManager::fetch(RepositoryView& repository, const std::string& remote, std::string& error) { - if (!prepareCredentials(repository, error)) return false; +bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error) +{ + if (!prepareCredentials(repository, error)) + return false; const std::vector args = remote.empty() - ? std::vector{"fetch", "--all", "--prune"} - : std::vector{"fetch", remote, "--prune"}; + ? std::vector{"fetch", "--all", "--prune"} + : std::vector{"fetch", remote, "--prune"}; return runGit(repository, args, "Fetch complete", error); } -bool GitManager::pull(RepositoryView& repository, int mode, std::string& error) { - if (!prepareCredentials(repository, error)) return false; - if (mode == 0) return fetch(repository, {}, error); +bool GitManager::pull(RepositoryView &repository, int mode, std::string &error) +{ + if (!prepareCredentials(repository, error)) + return false; + if (mode == 0) + return fetch(repository, {}, error); std::vector args{"pull"}; - if (mode == 1) args.push_back("--ff"); - else if (mode == 2) args.push_back("--ff-only"); - else if (mode == 3) args.push_back("--rebase"); + if (mode == 1) + args.push_back("--ff"); + else if (mode == 2) + args.push_back("--ff-only"); + else if (mode == 3) + args.push_back("--rebase"); return runGit(repository, args, "Pull complete", error); } -bool GitManager::push(RepositoryView& repository, std::string& error) { - if (!prepareCredentials(repository, error)) return false; - if (runGit(repository, {"push"}, "Push complete", error)) return true; - if (repository.remotes.empty()) return false; +bool GitManager::push(RepositoryView &repository, std::string &error) +{ + if (!prepareCredentials(repository, error)) + return false; + if (runGit(repository, {"push"}, "Push complete", error)) + return true; + if (repository.remotes.empty()) + return false; const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") != - repository.remotes.end() ? "origin" : repository.remotes.front(); + 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); } -bool GitManager::stash(RepositoryView& repository, std::string& error) { +bool GitManager::stash(RepositoryView &repository, std::string &error) +{ return runGit(repository, {"stash", "push", "--include-untracked", "-m", "Gitree stash"}, - "Changes stashed", error); + "Changes stashed", error); } -bool GitManager::popStash(RepositoryView& repository, std::string& error) { +bool GitManager::popStash(RepositoryView &repository, std::string &error) +{ return runGit(repository, {"stash", "pop"}, "Stash applied", error); } -bool GitManager::undoCommit(RepositoryView& repository, std::string& error) { +bool GitManager::undoCommit(RepositoryView &repository, std::string &error) +{ return runGit(repository, {"reset", "--soft", "HEAD~1"}, "Last commit moved back to staging", error); } -bool GitManager::redoCommit(RepositoryView& repository, std::string& error) { +bool GitManager::redoCommit(RepositoryView &repository, std::string &error) +{ return runGit(repository, {"reset", "--soft", "HEAD@{1}"}, "Commit restored from reflog", error); } -bool GitManager::stageAll(RepositoryView& repository, std::string& error) { +bool GitManager::stageAll(RepositoryView &repository, std::string &error) +{ return runGit(repository, {"add", "--all"}, "All changes staged", error); } -bool GitManager::stageFile(RepositoryView& repository, const std::string& path, std::string& error) { +bool GitManager::stageFile(RepositoryView &repository, const std::string &path, std::string &error) +{ return runGit(repository, {"add", "--", path}, "File staged", error); } -bool GitManager::unstageFile(RepositoryView& repository, const std::string& path, std::string& error) { - if (runGit(repository, {"restore", "--staged", "--", path}, "File unstaged", error)) return true; +bool GitManager::unstageFile(RepositoryView &repository, const std::string &path, std::string &error) +{ + if (runGit(repository, {"restore", "--staged", "--", path}, "File unstaged", error)) + return true; return runGit(repository, {"reset", "HEAD", "--", path}, "File unstaged", error); } -bool GitManager::discardFile(RepositoryView& repository, const std::string& path, std::string& error) { - if (runGit(repository, {"restore", "--worktree", "--", path}, "File changes discarded", error)) return true; +bool GitManager::discardFile(RepositoryView &repository, const std::string &path, std::string &error) +{ + if (runGit(repository, {"restore", "--worktree", "--", path}, "File changes discarded", error)) + return true; return runGit(repository, {"clean", "-f", "--", path}, "Untracked file removed", error); } -bool GitManager::discardAll(RepositoryView& repository, std::string& error) { - if (!runGit(repository, {"reset", "--hard", "HEAD"}, "Tracked changes discarded", error, false)) return false; +bool GitManager::discardAll(RepositoryView &repository, std::string &error) +{ + if (!runGit(repository, {"reset", "--hard", "HEAD"}, "Tracked changes discarded", error, false)) + return false; return runGit(repository, {"clean", "-fd"}, "All working changes discarded", error); } -bool GitManager::commit(RepositoryView& repository, const std::string& summary, - const std::string& description, bool amend, std::string& error) { +bool GitManager::commit(RepositoryView &repository, const std::string &summary, + const std::string &description, bool amend, std::string &error) +{ std::vector args{"commit"}; - if (amend) args.push_back("--amend"); + if (amend) + args.push_back("--amend"); args.insert(args.end(), {"-m", summary}); - if (!description.empty()) args.insert(args.end(), {"-m", description}); + if (!description.empty()) + args.insert(args.end(), {"-m", description}); return runGit(repository, args, amend ? "Commit amended" : "Commit created", error); } -bool GitManager::createBranch(RepositoryView& repository, const std::string& name, - const std::string& start_point, bool checkout, std::string& error) { +bool GitManager::createBranch(RepositoryView &repository, const std::string &name, + const std::string &start_point, bool checkout, std::string &error) +{ std::vector args{checkout ? "switch" : "branch"}; - if (checkout) args.insert(args.end(), {"-c", name}); - else args.push_back(name); - if (!start_point.empty()) args.push_back(start_point); + if (checkout) + args.insert(args.end(), {"-c", name}); + else + args.push_back(name); + if (!start_point.empty()) + args.push_back(start_point); return runGit(repository, args, checkout ? "Branch created and checked out" : "Branch created", error); } -bool GitManager::createTag(RepositoryView& repository, const std::string& name, - const std::string& target, std::string& error) { +bool GitManager::createTag(RepositoryView &repository, const std::string &name, + const std::string &target, std::string &error) +{ std::vector args{"tag", name}; - if (!target.empty()) args.push_back(target); + if (!target.empty()) + args.push_back(target); return runGit(repository, args, "Tag created", error); } -bool GitManager::addRemote(RepositoryView& repository, const std::string& name, - const std::string& url, std::string& error) { +bool GitManager::addRemote(RepositoryView &repository, const std::string &name, + const std::string &url, std::string &error) +{ repository.credentials_checked = false; return runGit(repository, {"remote", "add", name, url}, "Remote added", error); } -bool GitManager::addWorktree(RepositoryView& repository, const std::string& path, - const std::string& branch, std::string& error) { +bool GitManager::addWorktree(RepositoryView &repository, const std::string &path, + const std::string &branch, std::string &error) +{ std::vector args{"worktree", "add", path}; - if (!branch.empty()) args.push_back(branch); + if (!branch.empty()) + args.push_back(branch); return runGit(repository, args, "Worktree added", error); } -bool GitManager::addSubmodule(RepositoryView& repository, const std::string& url, - const std::string& path, std::string& error) { +bool GitManager::addSubmodule(RepositoryView &repository, const std::string &url, + const std::string &path, std::string &error) +{ std::vector args{"submodule", "add", url}; - if (!path.empty()) args.push_back(path); + if (!path.empty()) + args.push_back(path); return runGit(repository, args, "Submodule added", error); } -bool GitManager::checkoutBranch(RepositoryView& repository, const std::string& branch, std::string& error) { - git_reference* reference = nullptr; - if (git_branch_lookup(&reference, repository.repo, branch.c_str(), GIT_BRANCH_LOCAL) != 0) { +bool GitManager::checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error) +{ + git_reference *reference = nullptr; + if (git_branch_lookup(&reference, repository.repo, branch.c_str(), GIT_BRANCH_LOCAL) != 0) + { error = lastError("Unable to find branch"); return false; } - const char* reference_name = git_reference_name(reference); + const git_oid *reference_target = git_reference_target(reference); + git_oid target{}; + const bool has_target = reference_target != nullptr; + if (has_target) + git_oid_cpy(&target, reference_target); + const char *reference_name = git_reference_name(reference); const int set_head_result = git_repository_set_head(repository.repo, reference_name); git_reference_free(reference); - if (set_head_result != 0) { + if (set_head_result != 0) + { error = lastError("Unable to switch HEAD"); return false; } git_checkout_options options{}; git_checkout_options_init(&options, GIT_CHECKOUT_OPTIONS_VERSION); options.checkout_strategy = GIT_CHECKOUT_SAFE | GIT_CHECKOUT_RECREATE_MISSING; - if (git_checkout_head(repository.repo, &options) != 0) { + if (git_checkout_head(repository.repo, &options) != 0) + { error = lastError("Unable to checkout branch"); return false; } - return loadRepositoryData(repository, error); + if (!loadRepositoryData(repository, error)) + return false; + + auto find_target = [&]() { + return std::find_if(repository.commits.begin(), repository.commits.end(), [&](const CommitInfo &commit) { + return has_target && git_oid_equal(&commit.oid, &target) != 0; + }); + }; + auto target_commit = find_target(); + while (has_target && target_commit == repository.commits.end() && !repository.history_exhausted) + { + if (!loadMoreCommits(repository, 500, error)) + return false; + target_commit = find_target(); + } + if (target_commit != repository.commits.end()) + { + repository.selected_commit = static_cast(std::distance(repository.commits.begin(), target_commit)); + repository.scroll_to_commit = repository.selected_commit; + } + return true; } -bool GitManager::updateSubmodule(RepositoryView& repository, const std::string& name, std::string& error) { - git_submodule* submodule = nullptr; - if (git_submodule_lookup(&submodule, repository.repo, name.c_str()) != 0) { +bool GitManager::updateSubmodule(RepositoryView &repository, const std::string &name, std::string &error) +{ + git_submodule *submodule = nullptr; + if (git_submodule_lookup(&submodule, repository.repo, name.c_str()) != 0) + { error = lastError("Unable to find submodule"); return false; } @@ -652,7 +989,8 @@ bool GitManager::updateSubmodule(RepositoryView& repository, const std::string& options.checkout_opts.checkout_strategy = GIT_CHECKOUT_SAFE | GIT_CHECKOUT_RECREATE_MISSING; const int result = git_submodule_update(submodule, 1, &options); git_submodule_free(submodule); - if (result != 0) { + if (result != 0) + { error = lastError("Unable to update submodule"); return false; } @@ -660,42 +998,50 @@ bool GitManager::updateSubmodule(RepositoryView& repository, const std::string& return loadRepositoryData(repository, error); } -std::string GitManager::worktreePath(RepositoryView& repository, const std::string& name, std::string& error) { - git_worktree* worktree = nullptr; - if (git_worktree_lookup(&worktree, repository.repo, name.c_str()) != 0) { +std::string GitManager::worktreePath(RepositoryView &repository, const std::string &name, std::string &error) +{ + git_worktree *worktree = nullptr; + if (git_worktree_lookup(&worktree, repository.repo, name.c_str()) != 0) + { error = lastError("Unable to find worktree"); return {}; } - const char* path = git_worktree_path(worktree); + const char *path = git_worktree_path(worktree); std::string result = path ? path : ""; git_worktree_free(worktree); return result; } -bool GitManager::loadCommitChanges(RepositoryView& repository, int commit_index, std::string& error) { - if (commit_index < 0 || commit_index >= static_cast(repository.commits.size())) return false; - CommitInfo& info = repository.commits[commit_index]; - if (info.changes_loaded) return true; +bool GitManager::loadCommitChanges(RepositoryView &repository, int commit_index, std::string &error) +{ + if (commit_index < 0 || commit_index >= static_cast(repository.commits.size())) + return false; + CommitInfo &info = repository.commits[commit_index]; + if (info.changes_loaded) + return true; - git_commit* commit = nullptr; - git_commit* parent = nullptr; - git_tree* tree = nullptr; - git_tree* parent_tree = nullptr; - git_diff* diff = nullptr; + git_commit *commit = nullptr; + git_commit *parent = nullptr; + git_tree *tree = nullptr; + git_tree *parent_tree = nullptr; + git_diff *diff = nullptr; bool success = false; if (git_commit_lookup(&commit, repository.repo, &info.oid) == 0 && - git_commit_tree(&tree, commit) == 0) { + git_commit_tree(&tree, commit) == 0) + { if (git_commit_parentcount(commit) > 0 && git_commit_parent(&parent, commit, 0) == 0) git_commit_tree(&parent_tree, parent); git_diff_options options{}; git_diff_options_init(&options, GIT_DIFF_OPTIONS_VERSION); if (git_diff_tree_to_tree(&diff, repository.repo, parent_tree, tree, &options) == 0 && - git_diff_foreach(diff, diffFileCallback, nullptr, nullptr, nullptr, &info.changed_files) == 0) { + git_diff_foreach(diff, diffFileCallback, nullptr, nullptr, nullptr, &info.changed_files) == 0) + { info.changes_loaded = true; success = true; } } - if (!success) error = lastError("Unable to load changed files"); + if (!success) + error = lastError("Unable to load changed files"); git_diff_free(diff); git_tree_free(parent_tree); git_tree_free(tree); @@ -703,3 +1049,52 @@ bool GitManager::loadCommitChanges(RepositoryView& repository, int commit_index, git_commit_free(commit); return success; } + +bool GitManager::loadCommitFiles(RepositoryView &repository, int commit_index, std::string &error) +{ + if (commit_index < 0 || commit_index >= static_cast(repository.commits.size())) + return false; + CommitInfo &info = repository.commits[commit_index]; + if (info.all_files_loaded) + return true; + if (!loadCommitChanges(repository, commit_index, error)) + return false; + + git_commit *commit = nullptr; + git_tree *tree = nullptr; + bool success = false; + if (git_commit_lookup(&commit, repository.repo, &info.oid) == 0 && + git_commit_tree(&tree, commit) == 0 && + git_tree_walk(tree, GIT_TREEWALK_PRE, treeFileCallback, &info.all_files) == 0) + { + for (ChangedFile &file : info.all_files) + { + const auto changed = std::find_if(info.changed_files.begin(), info.changed_files.end(), + [&file](const ChangedFile &candidate) + { return candidate.path == file.path; }); + if (changed != info.changed_files.end()) + file.kind = changed->kind; + } + for (const ChangedFile &changed : info.changed_files) + { + const bool present = std::any_of(info.all_files.begin(), info.all_files.end(), + [&changed](const ChangedFile &file) + { return file.path == changed.path; }); + if (!present) + info.all_files.push_back(changed); + } + std::sort(info.all_files.begin(), info.all_files.end(), + [](const ChangedFile &left, const ChangedFile &right) + { return left.path < right.path; }); + info.all_files_loaded = true; + success = true; + } + if (!success) + { + info.all_files.clear(); + error = lastError("Unable to load repository files"); + } + git_tree_free(tree); + git_commit_free(commit); + return success; +} diff --git a/src/managers/git_manager.h b/src/managers/git_manager.h index 73c57dd..eae298d 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -4,57 +4,60 @@ #include #include -class GitManager { +class GitManager +{ public: GitManager(); ~GitManager(); - bool openRepository(RepositoryView& repository, const std::string& path, std::string& error); - bool initializeRepository(RepositoryView& repository, const std::string& path, std::string& error); - bool reload(RepositoryView& repository, std::string& error); - bool loadMoreCommits(RepositoryView& repository, size_t page_size, std::string& error); - bool checkoutBranch(RepositoryView& repository, const std::string& 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 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); - bool stageFile(RepositoryView& repository, const std::string& path, std::string& error); - bool unstageFile(RepositoryView& repository, const std::string& path, std::string& error); - bool discardFile(RepositoryView& repository, const std::string& path, std::string& error); - bool discardAll(RepositoryView& repository, std::string& error); - bool commit(RepositoryView& repository, const std::string& summary, - const std::string& description, bool amend, std::string& error); - bool createBranch(RepositoryView& repository, const std::string& name, - const std::string& start_point, bool checkout, std::string& error); - bool createTag(RepositoryView& repository, const std::string& name, - const std::string& target, std::string& error); - bool addRemote(RepositoryView& repository, const std::string& name, - const std::string& url, std::string& error); - bool addWorktree(RepositoryView& repository, const std::string& path, - const std::string& branch, std::string& error); - bool addSubmodule(RepositoryView& repository, const std::string& url, - const std::string& path, std::string& error); - 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 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); + bool openRepository(RepositoryView &repository, const std::string &path, std::string &error); + bool initializeRepository(RepositoryView &repository, const std::string &path, std::string &error); + bool reload(RepositoryView &repository, std::string &error); + bool loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error); + bool checkoutBranch(RepositoryView &repository, const std::string &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 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); + bool stageFile(RepositoryView &repository, const std::string &path, std::string &error); + bool unstageFile(RepositoryView &repository, const std::string &path, std::string &error); + bool discardFile(RepositoryView &repository, const std::string &path, std::string &error); + bool discardAll(RepositoryView &repository, std::string &error); + bool commit(RepositoryView &repository, const std::string &summary, + const std::string &description, bool amend, std::string &error); + bool createBranch(RepositoryView &repository, const std::string &name, + const std::string &start_point, bool checkout, std::string &error); + bool createTag(RepositoryView &repository, const std::string &name, + const std::string &target, std::string &error); + bool addRemote(RepositoryView &repository, const std::string &name, + const std::string &url, std::string &error); + bool addWorktree(RepositoryView &repository, const std::string &path, + const std::string &branch, std::string &error); + bool addSubmodule(RepositoryView &repository, const std::string &url, + const std::string &path, std::string &error); + 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: - static std::string lastError(const char* fallback); + static std::string lastError(const char *fallback); static std::string formatTime(git_time_t value, int offset_minutes); - void readBranches(RepositoryView& repository, git_branch_t type, std::vector& output); - 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 readBranches(RepositoryView &repository, git_branch_t type, std::vector &output); + void loadBranchDivergence(RepositoryView &repository); + 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); }; diff --git a/src/managers/user_data.cpp b/src/managers/user_data.cpp index 09142a5..2d9c18b 100644 --- a/src/managers/user_data.cpp +++ b/src/managers/user_data.cpp @@ -1,72 +1,88 @@ #include "user_data.h" -extern "C" { +#include + +extern "C" +{ #include } #include -#include #include #include #include -namespace { -std::filesystem::path roaming_directory() { -#ifdef _WIN32 - if (const char* appdata = std::getenv("APPDATA")) return appdata; -#endif - if (const char* home = std::getenv("HOME")) return std::filesystem::path(home) / ".config"; - return std::filesystem::temp_directory_path(); +namespace +{ + std::filesystem::path roaming_directory() + { + if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty()) + return config; + if (const auto temporary = izo::GetKnownPath(izo::KnownPath::Temporary); !temporary.empty()) + return temporary; + 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; + } } - -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; -} -} - -UserData::UserData() { +UserData::UserData() +{ directory_ = roaming_directory() / "Identity" / "Gitree"; std::filesystem::create_directories(directory_); imgui_ini_path_ = (directory_ / "imgui.ini").string(); load(); } -UserData::~UserData() { +UserData::~UserData() +{ save(); } -void UserData::load() { +void UserData::load() +{ const std::filesystem::path data_path = directory_ / "user_data.ikv"; - if (ikv_node_t* root = ikv_parse_file(data_path.string().c_str())) { - 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)) + if (ikv_node_t *root = ikv_parse_file(data_path.string().c_str())) + { + 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)) + 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, "pull_mode", IKV_INT)) + 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* heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) { + 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* history = object_value(root, "recently_closed", IKV_ARRAY)) { + if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) + { const uint32_t count = std::min(ikv_array_size(history), 12); - for (uint32_t index = 0; index < count; ++index) { - const char* path = ikv_as_string(ikv_array_get(history, index)); - if (path && *path) recently_closed_.emplace_back(path); + for (uint32_t index = 0; index < count; ++index) + { + const char *path = ikv_as_string(ikv_array_get(history, index)); + if (path && *path) + recently_closed_.emplace_back(path); } } - 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)) + 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)); + 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 : ""); } } @@ -75,80 +91,105 @@ void UserData::load() { 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); - for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f); - if (open_repositories_.empty()) active_repository_ = 0; - else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); + for (float &height : sidebar_section_heights_) + height = std::clamp(height, 42.0f, 500.0f); + if (open_repositories_.empty()) + active_repository_ = 0; + else + active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); return; } // Import the original files once when upgrading an existing installation. 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.rfind("sidebar_section_", 0) == 0) { + 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.rfind("sidebar_section_", 0) == 0) + { const size_t index = static_cast(std::stoul(key.substr(16))); float height = 0.0f; settings >> height; - if (index < sidebar_section_heights_.size()) sidebar_section_heights_[index] = height; + if (index < sidebar_section_heights_.size()) + 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); - for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f); + for (float &height : sidebar_section_heights_) + height = std::clamp(height, 42.0f, 500.0f); std::ifstream history(directory_ / "history.txt"); std::string path; - while (history >> std::quoted(path)) { - if (!path.empty()) recently_closed_.push_back(path); - if (recently_closed_.size() == 12) break; + while (history >> std::quoted(path)) + { + if (!path.empty()) + recently_closed_.push_back(path); + if (recently_closed_.size() == 12) + break; } std::ifstream session(directory_ / "session.txt"); session >> active_repository_; - while (session >> std::quoted(path)) open_repositories_.push_back(path); - if (open_repositories_.empty()) active_repository_ = 0; - else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); + while (session >> std::quoted(path)) + open_repositories_.push_back(path); + if (open_repositories_.empty()) + active_repository_ = 0; + else + active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); } -void UserData::addRecentlyClosed(const std::string& path) { - if (path.empty()) return; +void UserData::addRecentlyClosed(const std::string &path) +{ + if (path.empty()) + return; std::erase(recently_closed_, path); recently_closed_.insert(recently_closed_.begin(), path); - if (recently_closed_.size() > 12) recently_closed_.resize(12); + if (recently_closed_.size() > 12) + recently_closed_.resize(12); save(); } -void UserData::setRepositorySession(std::vector paths, size_t active_repository) { +void UserData::setRepositorySession(std::vector paths, size_t active_repository) +{ open_repositories_ = std::move(paths); active_repository_ = open_repositories_.empty() - ? 0 - : std::min(active_repository, open_repositories_.size() - 1); + ? 0 + : std::min(active_repository, open_repositories_.size() - 1); save(); } -void UserData::save() const { +void UserData::save() const +{ std::filesystem::create_directories(directory_); - ikv_node_t* root = ikv_create_object("gitree"); - if (!root) return; + ikv_node_t *root = ikv_create_object("gitree"); + if (!root) + return; - ikv_node_t* settings = ikv_object_add_object(root, "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_int(settings, "pull_mode", pull_mode_); - 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 *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* history = ikv_object_add_array(root, "recently_closed", IKV_STRING); - for (const auto& path : recently_closed_) ikv_array_add_string(history, path.c_str()); + 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()); - ikv_node_t* session = ikv_object_add_object(root, "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()); + 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()); ikv_write_file((directory_ / "user_data.ikv").string().c_str(), root); ikv_free(root); diff --git a/src/managers/user_data.h b/src/managers/user_data.h index 944f18c..04d2da9 100644 --- a/src/managers/user_data.h +++ b/src/managers/user_data.h @@ -5,15 +5,16 @@ #include #include -class UserData { +class UserData +{ public: UserData(); ~UserData(); - [[nodiscard]] const std::filesystem::path& directory() const { return directory_; } - [[nodiscard]] const std::string& imguiIniPath() const { return imgui_ini_path_; } - [[nodiscard]] const std::vector& recentlyClosed() const { return recently_closed_; } - [[nodiscard]] const std::vector& openRepositories() const { return open_repositories_; } + [[nodiscard]] const std::filesystem::path &directory() const { return directory_; } + [[nodiscard]] const std::string &imguiIniPath() const { return imgui_ini_path_; } + [[nodiscard]] const std::vector &recentlyClosed() const { return recently_closed_; } + [[nodiscard]] const std::vector &openRepositories() const { return open_repositories_; } [[nodiscard]] size_t activeRepository() const { return active_repository_; } [[nodiscard]] float sidebarWidth() const { return sidebar_width_; } [[nodiscard]] float detailsWidth() const { return details_width_; } @@ -23,8 +24,12 @@ public: void setSidebarWidth(float width) { sidebar_width_ = width; } void setDetailsWidth(float width) { details_width_ = width; } void setSidebarSectionHeight(size_t index, float height) { sidebar_section_heights_.at(index) = height; } - void setPullMode(int mode) { pull_mode_ = mode; save(); } - void addRecentlyClosed(const std::string& path); + void setPullMode(int mode) + { + pull_mode_ = mode; + save(); + } + void addRecentlyClosed(const std::string &path); void setRepositorySession(std::vector paths, size_t active_repository); void save() const; diff --git a/src/managers/window_manager.h b/src/managers/window_manager.h index 2707b57..8c2c10f 100644 --- a/src/managers/window_manager.h +++ b/src/managers/window_manager.h @@ -2,30 +2,31 @@ struct GLFWwindow; -class WindowManager { +class WindowManager +{ public: WindowManager() = default; ~WindowManager(); - WindowManager(const WindowManager&) = delete; - WindowManager& operator=(const WindowManager&) = delete; + WindowManager(const WindowManager &) = delete; + WindowManager &operator=(const WindowManager &) = delete; - bool create(const char* title, int width, int height); + bool create(const char *title, int width, int height); void destroy(); void pollEvents(); void swapBuffers(); void requestClose(); [[nodiscard]] bool shouldClose() const; - [[nodiscard]] GLFWwindow* nativeWindow() const { return window_; } + [[nodiscard]] GLFWwindow *nativeWindow() const { return window_; } [[nodiscard]] float dpiScale() const { return dpi_scale_; } bool consumeDpiChange(); private: - static void contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale); + static void contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale); void updateDpi(float scale); void applyNativeCaption() const; - GLFWwindow* window_ = nullptr; + GLFWwindow *window_ = nullptr; float dpi_scale_ = 1.0f; bool dpi_changed_ = false; }; diff --git a/src/models/repository.h b/src/models/repository.h index 8c45f81..f8c8d4b 100644 --- a/src/models/repository.h +++ b/src/models/repository.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -28,19 +29,28 @@ struct WorkingFile { bool staged = false; }; +struct BranchDivergence { + size_t ahead = 0; + size_t behind = 0; +}; + struct CommitInfo { git_oid oid{}; std::string short_id; std::string summary; + std::string description; std::string author; std::string email; std::string date; int parents = 0; int lane = 0; + int graph_color = 0; std::vector parent_ids; std::vector refs; std::vector changed_files; + std::vector all_files; bool changes_loaded = false; + bool all_files_loaded = false; }; struct RepositoryView { @@ -53,13 +63,17 @@ struct RepositoryView { std::string branch = "detached"; std::vector local_branches; std::vector remote_branches; + std::map local_branch_divergence; + std::map remote_branch_divergence; std::vector remotes; std::vector worktrees; std::set worktree_branches; std::vector submodules; + std::map submodule_statuses; std::vector commits; std::vector working_files; int selected_commit = 0; + int scroll_to_commit = -1; RepositoryView() = default; ~RepositoryView() { close(); } diff --git a/src/ui/diff_viewer.cpp b/src/ui/diff_viewer.cpp index a8b180a..9cb14f3 100644 --- a/src/ui/diff_viewer.cpp +++ b/src/ui/diff_viewer.cpp @@ -5,13 +5,16 @@ #include #include -#include #include #include +#include #include #include +#include +#include #include +#include namespace { float scaled(float value, float scale) { return value * scale; } @@ -38,21 +41,239 @@ void parseRange(const std::string& header, char marker, int& line) { } } -ImU32 syntaxColor(const std::string& text) { - const size_t first = text.find_first_not_of(" \t"); - if (first == std::string::npos) return IM_COL32(210, 214, 220, 255); - const std::string_view value(text.c_str() + first, text.size() - first); - if (value.starts_with("//") || value.starts_with("# ")) return IM_COL32(129, 184, 125, 255); - if (value.starts_with('#')) return IM_COL32(205, 157, 222, 255); - static constexpr const char* keywords[] = { - "class ", "struct ", "enum ", "if ", "else", "for ", "while ", "return ", - "const ", "static ", "void ", "bool ", "int ", "float ", "auto ", "namespace " +enum class SyntaxLanguage { plain, c, cpp, csharp, lua, python, javascript }; +enum class SyntaxContinuation { none, block_comment, lua_comment, python_single, python_double, template_string }; + +struct SyntaxState { + SyntaxContinuation continuation = SyntaxContinuation::none; +}; + +ImU32 blameColor(std::string_view hash, int alpha = 255) { + static constexpr ImU32 colors[] = { + IM_COL32(24, 181, 204, 255), IM_COL32(73, 123, 235, 255), IM_COL32(200, 64, 200, 255), + IM_COL32(239, 79, 89, 255), IM_COL32(255, 122, 41, 255), IM_COL32(240, 186, 46, 255), + IM_COL32(64, 186, 128, 255), }; - for (const char* keyword : keywords) - if (value.starts_with(keyword)) return IM_COL32(124, 177, 228, 255); - if (value.find('"') != std::string_view::npos || value.find('\'') != std::string_view::npos) - return IM_COL32(226, 166, 140, 255); - return IM_COL32(218, 221, 226, 255); + uint32_t value = 2166136261u; + for (const unsigned char character : hash) value = (value ^ character) * 16777619u; + return (colors[value % std::size(colors)] & ~IM_COL32_A_MASK) | (static_cast(alpha) << IM_COL32_A_SHIFT); +} + +SyntaxLanguage languageForPath(const std::string& path) { + std::string extension = std::filesystem::path(path).extension().string(); + std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + if (extension == ".c") return SyntaxLanguage::c; + if (extension == ".h" || extension == ".hh" || extension == ".hpp" || extension == ".hxx" || + extension == ".cc" || extension == ".cpp" || extension == ".cxx") return SyntaxLanguage::cpp; + if (extension == ".cs") return SyntaxLanguage::csharp; + if (extension == ".lua") return SyntaxLanguage::lua; + if (extension == ".py" || extension == ".pyw") return SyntaxLanguage::python; + if (extension == ".js" || extension == ".jsx" || extension == ".mjs" || extension == ".cjs") + return SyntaxLanguage::javascript; + return SyntaxLanguage::plain; +} + +bool wordIs(std::string_view word, std::initializer_list values) { + return std::find(values.begin(), values.end(), word) != values.end(); +} + +bool isKeyword(SyntaxLanguage language, std::string_view word) { + static constexpr std::string_view common[] = { + "break", "case", "continue", "default", "do", "else", "for", "if", "return", "switch", "while" + }; + if (std::find(std::begin(common), std::end(common), word) != std::end(common)) return true; + switch (language) { + case SyntaxLanguage::c: + return wordIs(word, {"const", "enum", "extern", "goto", "register", "sizeof", "static", "struct", "typedef", "union", "volatile"}); + case SyntaxLanguage::cpp: + return wordIs(word, {"alignas", "auto", "class", "concept", "const", "constexpr", "consteval", "constinit", "co_await", "co_return", "co_yield", "decltype", "delete", "enum", "explicit", "export", "extern", "friend", "inline", "mutable", "namespace", "new", "noexcept", "operator", "override", "private", "protected", "public", "requires", "sizeof", "static", "struct", "template", "this", "thread_local", "throw", "try", "typedef", "typename", "union", "using", "virtual", "volatile"}); + case SyntaxLanguage::csharp: + return wordIs(word, {"abstract", "as", "async", "await", "base", "class", "const", "delegate", "enum", "event", "explicit", "extern", "finally", "fixed", "foreach", "implicit", "in", "interface", "internal", "is", "lock", "namespace", "new", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "record", "ref", "sealed", "sizeof", "stackalloc", "static", "struct", "this", "throw", "try", "typeof", "unchecked", "unsafe", "using", "virtual", "volatile", "where", "yield"}); + case SyntaxLanguage::lua: + return wordIs(word, {"and", "elseif", "end", "false", "function", "goto", "in", "local", "nil", "not", "or", "repeat", "then", "true", "until"}); + case SyntaxLanguage::python: + return wordIs(word, {"and", "as", "assert", "async", "await", "class", "def", "del", "elif", "except", "False", "finally", "from", "global", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "True", "try", "with", "yield"}); + case SyntaxLanguage::javascript: + return wordIs(word, {"async", "await", "catch", "class", "const", "debugger", "delete", "export", "extends", "false", "finally", "from", "function", "get", "import", "in", "instanceof", "let", "new", "null", "of", "set", "static", "super", "this", "throw", "true", "try", "typeof", "undefined", "var", "void", "with", "yield"}); + default: + return false; + } +} + +bool isTypeWord(SyntaxLanguage language, std::string_view word) { + if (language == SyntaxLanguage::python || language == SyntaxLanguage::lua || language == SyntaxLanguage::plain) + return false; + return wordIs(word, {"bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "int8_t", "int16_t", "int32_t", "int64_t", "long", "nint", "nuint", "object", "sbyte", "short", "signed", "size_t", "string", "uint", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "ulong", "unsigned", "ushort", "var", "void", "wchar_t"}); +} + +void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text, + SyntaxLanguage language, SyntaxState& state) { + constexpr ImU32 normal = IM_COL32(218, 221, 226, 255); + constexpr ImU32 keyword = IM_COL32(198, 139, 230, 255); + constexpr ImU32 type = IM_COL32(91, 198, 190, 255); + constexpr ImU32 string = IM_COL32(226, 166, 140, 255); + constexpr ImU32 number = IM_COL32(181, 206, 126, 255); + constexpr ImU32 comment = IM_COL32(112, 153, 105, 255); + constexpr ImU32 function = IM_COL32(220, 199, 128, 255); + constexpr ImU32 preprocessor = IM_COL32(205, 157, 222, 255); + + const auto drawSpan = [&](size_t begin, size_t end, ImU32 color) { + if (end <= begin) return; + const char* first = text.data() + begin; + const char* last = text.data() + end; + draw->AddText(position, color, first, last); + position.x += ImGui::CalcTextSize(first, last, false).x; + }; + if (language == SyntaxLanguage::plain) { + drawSpan(0, text.size(), normal); + return; + } + + const size_t first_non_space = text.find_first_not_of(" \t"); + if ((language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) && + first_non_space != std::string::npos && text[first_non_space] == '#') { + drawSpan(0, first_non_space, normal); + drawSpan(first_non_space, text.size(), preprocessor); + return; + } + + size_t cursor = 0; + while (cursor < text.size()) { + if (state.continuation == SyntaxContinuation::block_comment) { + const size_t end = text.find("*/", cursor); + if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; } + drawSpan(cursor, end + 2, comment); + cursor = end + 2; + state.continuation = SyntaxContinuation::none; + continue; + } + if (state.continuation == SyntaxContinuation::lua_comment) { + const size_t end = text.find("]]", cursor); + if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; } + drawSpan(cursor, end + 2, comment); + cursor = end + 2; + state.continuation = SyntaxContinuation::none; + continue; + } + if (state.continuation == SyntaxContinuation::python_single || + state.continuation == SyntaxContinuation::python_double) { + const std::string_view delimiter = state.continuation == SyntaxContinuation::python_single ? "'''" : "\"\"\""; + const size_t end = text.find(delimiter, cursor); + if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; } + drawSpan(cursor, end + delimiter.size(), string); + cursor = end + delimiter.size(); + state.continuation = SyntaxContinuation::none; + continue; + } + if (state.continuation == SyntaxContinuation::template_string) { + const size_t end = text.find('`', cursor); + if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; } + drawSpan(cursor, end + 1, string); + cursor = end + 1; + state.continuation = SyntaxContinuation::none; + continue; + } + + const bool slash_comments = language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || + language == SyntaxLanguage::csharp || language == SyntaxLanguage::javascript; + if (slash_comments && text.compare(cursor, 2, "//") == 0) { + drawSpan(cursor, text.size(), comment); + return; + } + if (slash_comments && text.compare(cursor, 2, "/*") == 0) { + const size_t end = text.find("*/", cursor + 2); + if (end == std::string::npos) { + drawSpan(cursor, text.size(), comment); + state.continuation = SyntaxContinuation::block_comment; + return; + } + drawSpan(cursor, end + 2, comment); + cursor = end + 2; + continue; + } + if (language == SyntaxLanguage::python && text[cursor] == '#') { + drawSpan(cursor, text.size(), comment); + return; + } + if (language == SyntaxLanguage::lua && text.compare(cursor, 4, "--[[") == 0) { + const size_t end = text.find("]]", cursor + 4); + if (end == std::string::npos) { + drawSpan(cursor, text.size(), comment); + state.continuation = SyntaxContinuation::lua_comment; + return; + } + drawSpan(cursor, end + 2, comment); + cursor = end + 2; + continue; + } + if (language == SyntaxLanguage::lua && text.compare(cursor, 2, "--") == 0) { + drawSpan(cursor, text.size(), comment); + return; + } + if (language == SyntaxLanguage::python && + (text.compare(cursor, 3, "'''") == 0 || text.compare(cursor, 3, "\"\"\"") == 0)) { + const std::string_view delimiter(text.data() + cursor, 3); + const size_t end = text.find(delimiter, cursor + 3); + if (end == std::string::npos) { + drawSpan(cursor, text.size(), string); + state.continuation = delimiter[0] == '\'' ? SyntaxContinuation::python_single : SyntaxContinuation::python_double; + return; + } + drawSpan(cursor, end + 3, string); + cursor = end + 3; + continue; + } + if (text[cursor] == '\'' || text[cursor] == '"' || + (language == SyntaxLanguage::javascript && text[cursor] == '`')) { + const char quote = text[cursor]; + size_t end = cursor + 1; + bool escaped = false; + for (; end < text.size(); ++end) { + if (!escaped && text[end] == quote) { ++end; break; } + escaped = !escaped && text[end] == '\\'; + if (text[end] != '\\') escaped = false; + } + const bool closed = end <= text.size() && end > cursor + 1 && text[end - 1] == quote; + drawSpan(cursor, end, string); + if (quote == '`' && !closed) state.continuation = SyntaxContinuation::template_string; + cursor = end; + continue; + } + if (std::isdigit(static_cast(text[cursor]))) { + size_t end = cursor + 1; + while (end < text.size() && (std::isalnum(static_cast(text[end])) || + text[end] == '.' || text[end] == '_')) ++end; + drawSpan(cursor, end, number); + cursor = end; + continue; + } + if (std::isalpha(static_cast(text[cursor])) || text[cursor] == '_') { + size_t end = cursor + 1; + while (end < text.size() && (std::isalnum(static_cast(text[end])) || text[end] == '_')) ++end; + const std::string_view word(text.data() + cursor, end - cursor); + size_t next = end; + while (next < text.size() && std::isspace(static_cast(text[next]))) ++next; + const ImU32 color = isKeyword(language, word) ? keyword : isTypeWord(language, word) ? type : + next < text.size() && text[next] == '(' ? function : normal; + drawSpan(cursor, end, color); + cursor = end; + continue; + } + size_t end = cursor + 1; + while (end < text.size()) { + const unsigned char value = static_cast(text[end]); + const bool token_start = std::isalnum(value) || text[end] == '_' || text[end] == '\'' || + text[end] == '"' || (language == SyntaxLanguage::javascript && text[end] == '`') || + (slash_comments && text[end] == '/') || (language == SyntaxLanguage::python && text[end] == '#') || + (language == SyntaxLanguage::lua && text[end] == '-'); + if (token_start) break; + ++end; + } + drawSpan(cursor, end, normal); + cursor = end; + } } bool compactButton(const char* label, bool active = false) { @@ -66,13 +287,28 @@ bool compactButton(const char* label, bool active = false) { void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path, bool staged, std::string& notice) { path_ = path; + commit_id_.clear(); staged_ = staged; mode_ = Mode::diff; reload(repository, manager, notice); } +void DiffViewer::openCommit(RepositoryView& repository, GitManager& manager, + const std::string& path, const std::string& commit_id, std::string& notice) { + path_ = path; + commit_id_ = commit_id; + staged_ = false; + mode_ = Mode::diff; + reload(repository, manager, notice); + if (hunks_.empty()) { + mode_ = Mode::file; + loadSupplement(repository, manager, mode_, notice); + } +} + void DiffViewer::close() { path_.clear(); + commit_id_.clear(); file_header_.clear(); hunks_.clear(); file_lines_.clear(); @@ -118,16 +354,68 @@ void DiffViewer::parseDiff(const std::string& text) { } } +void DiffViewer::parseBlame(const std::string& text) { + blame_lines_.clear(); + const std::vector lines = splitLines(text); + std::string previous_hash; + for (size_t index = 0; index < lines.size();) { + std::istringstream header(lines[index++]); + BlameLine line; + int original_line = 0; + if (!(header >> line.hash >> original_line >> line.line_number)) continue; + if (line.hash.size() < 8 || !std::all_of(line.hash.begin(), line.hash.end(), [](unsigned char value) { + return std::isxdigit(value) != 0; + })) continue; + + std::time_t author_time = 0; + while (index < lines.size()) { + const std::string& field = lines[index++]; + if (!field.empty() && field.front() == '\t') { + line.text = field.substr(1); + break; + } + const size_t separator = field.find(' '); + const std::string_view key(field.data(), separator == std::string::npos ? field.size() : separator); + const std::string value = separator == std::string::npos ? std::string{} : field.substr(separator + 1); + if (key == "author") line.author = value; + else if (key == "author-time") { + try { author_time = static_cast(std::stoll(value)); } + catch (...) { author_time = 0; } + } else if (key == "summary") line.summary = value; + } + if (author_time != 0) { + std::tm date{}; +#ifdef _WIN32 + localtime_s(&date, &author_time); +#else + localtime_r(&author_time, &date); +#endif + std::ostringstream formatted; + formatted << std::put_time(&date, "%Y-%m-%d"); + line.date = formatted.str(); + } + line.show_attribution = line.hash != previous_hash; + previous_hash = line.hash; + blame_lines_.push_back(std::move(line)); + } +} + void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::string& notice) { - std::vector arguments{"diff", "--no-ext-diff", "--no-color", "--unified=3"}; - if (staged_) arguments.push_back("--cached"); + std::vector arguments; + if (commit_id_.empty()) { + arguments = {"diff", "--no-ext-diff", "--no-color", "--unified=3"}; + if (staged_) arguments.push_back("--cached"); + } else { + arguments = {"show", "--first-parent", "--format=", "--no-ext-diff", "--no-color", + "--unified=3", commit_id_}; + } arguments.insert(arguments.end(), {"--", path_}); std::string output; std::string error; if (!manager.captureGit(repository, arguments, output, error)) notice = error; parseDiff(output); - if (hunks_.empty()) { + if (hunks_.empty() && commit_id_.empty()) { const auto file = std::find_if(repository.working_files.begin(), repository.working_files.end(), [this](const WorkingFile& item) { return item.path == path_; }); if (file != repository.working_files.end() && file->kind == FileChangeKind::added && !staged_) { @@ -158,7 +446,10 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager, std::string output; std::string error; if (mode == Mode::file) { - if (staged_) { + if (!commit_id_.empty()) { + if (!manager.captureGit(repository, {"show", commit_id_ + ":" + path_}, output, error)) + notice = error; + } else if (staged_) { if (!manager.captureGit(repository, {"show", ":" + path_}, output, error)) notice = error; } else { std::ifstream stream(std::filesystem::path(repository.path) / path_, std::ios::binary); @@ -168,12 +459,17 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager, } file_lines_ = splitLines(output); } else if (mode == Mode::blame) { - if (!manager.captureGit(repository, {"blame", "--date=short", "--", path_}, output, error)) notice = error; - blame_lines_ = splitLines(output); + std::vector arguments{"blame", "--line-porcelain"}; + if (!commit_id_.empty()) arguments.push_back(commit_id_); + arguments.insert(arguments.end(), {"--", path_}); + if (!manager.captureGit(repository, arguments, output, error)) notice = error; + parseBlame(output); } else if (mode == Mode::history) { - if (!manager.captureGit(repository, - {"log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s", "--", path_}, - output, error)) notice = error; + std::vector arguments{ + "log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s"}; + if (!commit_id_.empty()) arguments.push_back(commit_id_); + arguments.insert(arguments.end(), {"--", path_}); + if (!manager.captureGit(repository, arguments, output, error)) notice = error; history_lines_ = splitLines(output); } } @@ -187,7 +483,10 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca ImGui::TextUnformatted(path_.c_str()); ImGui::SameLine(std::max(scaled(240, scale), ImGui::GetWindowWidth() - scaled(455, scale))); - if (compactButton(staged_ ? "Staged" : "Unstaged", true)) {} + const bool historical = !commit_id_.empty(); + const std::string source_label = historical ? "Commit " + commit_id_.substr(0, 8) + : staged_ ? "Staged" : "Unstaged"; + if (compactButton(source_label.c_str(), true)) {} ImGui::SameLine(); if (compactButton("File View", mode_ == Mode::file)) { mode_ = Mode::file; loadSupplement(repository, manager, mode_, notice); @@ -202,25 +501,23 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca if (compactButton("History", mode_ == Mode::history)) { mode_ = Mode::history; loadSupplement(repository, manager, mode_, notice); } - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1)); - if (compactButton(staged_ ? "Unstage File" : "Stage File")) { - const bool changed = staged_ ? manager.unstageFile(repository, path_, notice) - : manager.stageFile(repository, path_, notice); - if (changed) { staged_ = !staged_; reload(repository, manager, notice); } + if (!historical) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1)); + if (compactButton(staged_ ? "Unstage File" : "Stage File")) { + const bool changed = staged_ ? manager.unstageFile(repository, path_, notice) + : manager.stageFile(repository, path_, notice); + if (changed) { staged_ = !staged_; reload(repository, manager, notice); } + } + ImGui::PopStyleColor(); } - ImGui::PopStyleColor(); ImGui::SameLine(); if (compactButton(ICON_FA_XMARK)) close(); ImGui::Separator(); if (!path_.empty()) { - if (compactButton(ICON_FA_PEN " Edit This File")) { - std::string error; - if (!izo::OpenPath(std::filesystem::path(repository.path) / path_, &error)) notice = error; - } - ImGui::SameLine(ImGui::GetWindowWidth() - scaled(116, scale)); + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale)); ImGui::TextDisabled("UTF-8"); - if (!staged_) { + if (!historical && !staged_) { ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1)); if (compactButton(ICON_FA_TRASH_CAN)) { @@ -235,7 +532,9 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca ImGuiWindowFlags_HorizontalScrollbar); const float row_height = scaled(21, scale); const float number_width = scaled(48, scale); - auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind) { + const SyntaxLanguage language = languageForPath(path_); + auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind, + SyntaxLanguage line_language, SyntaxState& syntax_state) { ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height}); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); @@ -256,10 +555,14 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca draw->AddText({minimum.x + number_width * 2 + scaled(5, scale), minimum.y + scaled(2, scale)}, kind == LineKind::added ? IM_COL32(87, 190, 112, 255) : kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : IM_COL32(148, 154, 164, 255), marker_text); - draw->AddText({minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)}, - syntaxColor(text), text.c_str()); + drawSyntaxText(draw, + {minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)}, + text, line_language, syntax_state); }; + // Keep the first source row clear of the toolbar and aligned with a normal row inset. + ImGui::Dummy({0.0f, row_height}); + if (mode_ == Mode::diff) { if (hunks_.empty()) ImGui::TextDisabled("No textual diff is available for this file."); int line_id = 0; @@ -267,12 +570,14 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca enum class HunkAction { none, stage, discard, unstage }; HunkAction pending_action = HunkAction::none; for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) { + SyntaxState old_syntax; + SyntaxState new_syntax; ImGui::PushID(static_cast(hunk_index)); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.56f, 0.70f, 0.90f, 1)); ImGui::TextUnformatted(hunks_[hunk_index].header.c_str()); ImGui::PopStyleColor(); ImGui::SameLine(std::max(scaled(220, scale), ImGui::GetWindowWidth() - scaled(205, scale))); - if (!staged_) { + if (!historical && !staged_) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1)); if (ImGui::SmallButton("Discard Hunk")) { pending_hunk = static_cast(hunk_index); @@ -286,13 +591,15 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca pending_action = HunkAction::stage; } ImGui::PopStyleColor(); - } else if (ImGui::SmallButton("Unstage Hunk")) { + } else if (!historical && ImGui::SmallButton("Unstage Hunk")) { pending_hunk = static_cast(hunk_index); pending_action = HunkAction::unstage; } for (const Line& line : hunks_[hunk_index].lines) { ImGui::PushID(line_id++); - draw_line(line.text, line.old_number, line.new_number, line.kind); + SyntaxState& syntax_state = line.kind == LineKind::removed ? old_syntax : new_syntax; + draw_line(line.text, line.old_number, line.new_number, line.kind, language, syntax_state); + if (line.kind == LineKind::context) old_syntax = new_syntax; ImGui::PopID(); } ImGui::PopID(); @@ -303,13 +610,67 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice)) reload(repository, manager, notice); } + } else if (mode_ == Mode::blame) { + SyntaxState syntax_state; + const float blame_number_width = scaled(42.0f, scale); + const float attribution_width = scaled(285.0f, scale); + for (size_t index = 0; index < blame_lines_.size(); ++index) { + const BlameLine& line = blame_lines_[index]; + ImGui::PushID(static_cast(index)); + ImGui::InvisibleButton("##blame_line", + {std::max(ImGui::GetContentRegionAvail().x, scaled(900.0f, scale)), row_height}); + const ImVec2 minimum = ImGui::GetItemRectMin(); + const ImVec2 maximum = ImGui::GetItemRectMax(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + const ImU32 color = blameColor(line.hash); + draw->AddRectFilled(minimum, + {minimum.x + blame_number_width + attribution_width, maximum.y}, blameColor(line.hash, 18)); + draw->AddRectFilled( + {minimum.x + blame_number_width + attribution_width - scaled(2.0f, scale), minimum.y}, + {minimum.x + blame_number_width + attribution_width, maximum.y}, color); + + char line_number[16]{}; + std::snprintf(line_number, sizeof(line_number), "%d", line.line_number); + draw->AddText({minimum.x + scaled(6.0f, scale), minimum.y + scaled(2.0f, scale)}, + IM_COL32(151, 158, 169, 255), line_number); + + if (line.show_attribution) { + const float attribution_left = minimum.x + blame_number_width + scaled(7.0f, scale); + const float attribution_right = minimum.x + blame_number_width + attribution_width - scaled(8.0f, scale); + draw->AddCircleFilled( + {attribution_left + scaled(4.0f, scale), minimum.y + row_height * 0.5f}, + scaled(3.5f, scale), color); + const float date_width = ImGui::CalcTextSize(line.date.c_str()).x; + draw->PushClipRect( + {attribution_left + scaled(14.0f, scale), minimum.y}, + {attribution_right - date_width - scaled(9.0f, scale), maximum.y}, true); + draw->AddText({attribution_left + scaled(14.0f, scale), minimum.y + scaled(2.0f, scale)}, + IM_COL32(205, 211, 220, 255), line.summary.c_str()); + draw->PopClipRect(); + draw->AddText({attribution_right - date_width, minimum.y + scaled(2.0f, scale)}, + IM_COL32(139, 147, 159, 255), line.date.c_str()); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort) && + ImGui::GetIO().MousePos.x < minimum.x + blame_number_width + attribution_width) { + ImGui::SetTooltip("%s\n%s\n%s %s", + line.summary.c_str(), line.author.c_str(), line.hash.substr(0, 10).c_str(), line.date.c_str()); + } + } + drawSyntaxText(draw, + {minimum.x + blame_number_width + attribution_width + scaled(10.0f, scale), + minimum.y + scaled(2.0f, scale)}, + line.text, language, syntax_state); + ImGui::PopID(); + } + if (blame_lines_.empty()) ImGui::TextDisabled("No blame data is available for this file."); } else { - const std::vector* lines = mode_ == Mode::file ? &file_lines_ : - mode_ == Mode::blame ? &blame_lines_ : &history_lines_; + const std::vector* lines = mode_ == Mode::file ? &file_lines_ : &history_lines_; + SyntaxState syntax_state; + const SyntaxLanguage line_language = mode_ == Mode::file ? language : SyntaxLanguage::plain; for (size_t index = 0; index < lines->size(); ++index) { ImGui::PushID(static_cast(index)); draw_line((*lines)[index], mode_ == Mode::file ? static_cast(index + 1) : 0, - mode_ == Mode::file ? static_cast(index + 1) : 0, LineKind::context); + mode_ == Mode::file ? static_cast(index + 1) : 0, LineKind::context, + line_language, syntax_state); ImGui::PopID(); } if (lines->empty()) ImGui::TextDisabled("No data is available for this view."); diff --git a/src/ui/diff_viewer.h b/src/ui/diff_viewer.h index a703919..88b968d 100644 --- a/src/ui/diff_viewer.h +++ b/src/ui/diff_viewer.h @@ -10,6 +10,8 @@ class DiffViewer { public: void open(RepositoryView& repository, GitManager& manager, const std::string& path, bool staged, std::string& notice); + void openCommit(RepositoryView& repository, GitManager& manager, const std::string& path, + const std::string& commit_id, std::string& notice); void close(); bool isOpen() const { return !path_.empty(); } void draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice); @@ -29,17 +31,28 @@ private: std::vector lines; std::string patch; }; + struct BlameLine { + std::string hash; + std::string author; + std::string date; + std::string summary; + std::string text; + int line_number = 0; + bool show_attribution = false; + }; std::string path_; + std::string commit_id_; bool staged_ = false; Mode mode_ = Mode::diff; std::string file_header_; std::vector hunks_; std::vector file_lines_; - std::vector blame_lines_; + std::vector blame_lines_; std::vector history_lines_; void reload(RepositoryView& repository, GitManager& manager, std::string& notice); void loadSupplement(RepositoryView& repository, GitManager& manager, Mode mode, std::string& notice); void parseDiff(const std::string& text); + void parseBlame(const std::string& text); }; diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 75706f1..35309b5 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -3,10 +3,13 @@ #include #include #include +#include #include +#include #include #include "ui/gitree_ui.h" #include "managers/application_manager.h" +#include "managers/application_icon_cache.h" #include "managers/avatar_cache.h" #include "managers/git_manager.h" #include "managers/user_data.h" @@ -22,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +48,7 @@ bool g_init_popup = false; bool g_about_popup = false; bool g_licenses_popup = false; bool g_tag_create_popup = false; +bool g_branch_create_popup = false; bool g_remote_add_popup = false; bool g_worktree_add_popup = false; bool g_submodule_add_popup = false; @@ -54,22 +59,27 @@ std::array g_git_path{}; std::string g_git_target; std::array g_inline_branch_name{}; std::string g_inline_branch_target; +std::string g_pending_branch_checkout; +std::string g_pending_commit_jump; RepositoryView* g_inline_branch_repository = nullptr; int g_inline_branch_commit = -1; bool g_focus_inline_branch = false; enum class FileViewMode { path, tree }; FileViewMode g_file_view_mode = FileViewMode::path; bool g_view_all_files = false; +bool g_file_sort_ascending = true; bool g_request_branch_selector = false; std::array g_commit_summary{}; std::array g_commit_description{}; float g_ui_scale = 1.0f; float g_sidebar_width = 230.0f; float g_details_width = 368.0f; +float g_commit_message_height = 125.0f; DiffViewer g_diff_viewer; WindowManager* g_window_manager = nullptr; GitManager* g_git_manager = nullptr; ApplicationManager* g_application_manager = nullptr; +ApplicationIconCache* g_application_icon_cache = nullptr; ExternalApplicationId g_open_in_application = ExternalApplicationId::visual_studio_code; ImFont* g_outline_icon_font = nullptr; float g_outline_icon_size = 15.0f; @@ -81,6 +91,87 @@ constexpr const char* ICON_TB_TAG = "\xee\xa4\x80"; float ui(float value) { return value * g_ui_scale; } RepositoryView& repo() { return *g_tabs.at(g_active_tab); } +constexpr float sidebar_child_indent = 24.0f; +constexpr float sidebar_nested_indent = 18.0f; +ImVec4 change_color(FileChangeKind kind); + +class ScopedUiScale { +public: + explicit ScopedUiScale(float factor) : previous_scale_(g_ui_scale) { + g_ui_scale *= factor; + ImGui::PushFont(nullptr, ImGui::GetStyle().FontSizeBase * factor); + } + ~ScopedUiScale() { + ImGui::PopFont(); + g_ui_scale = previous_scale_; + } + + ScopedUiScale(const ScopedUiScale&) = delete; + ScopedUiScale& operator=(const ScopedUiScale&) = delete; + +private: + float previous_scale_; +}; + +std::string oid_string(const git_oid& oid) { + char value[GIT_OID_SHA1_HEXSIZE + 1]{}; + git_oid_tostr(value, sizeof(value), &oid); + return value; +} + +bool copy_to_clipboard(std::string_view text, const char* description) { + std::string error; + if (!izo::SetClipboardText(text, &error)) { + g_notice = error.empty() ? "Unable to copy to the clipboard" : error; + return false; + } + g_notice = std::string("Copied ") + description; + return true; +} + +void draw_dotted_bezier(ImDrawList* draw, const ImVec2& start, const ImVec2& control_start, + const ImVec2& control_end, const ImVec2& end, ImU32 color, float radius, float spacing) { + const float estimated_length = std::hypot(end.x - start.x, end.y - start.y) + + std::hypot(control_start.x - start.x, control_start.y - start.y) + + std::hypot(end.x - control_end.x, end.y - control_end.y); + const int dots = std::max(2, static_cast(estimated_length / spacing)); + for (int index = 0; index <= dots; ++index) { + const float t = static_cast(index) / static_cast(dots); + const float inverse = 1.0f - t; + const float a = inverse * inverse * inverse; + const float b = 3.0f * inverse * inverse * t; + const float c = 3.0f * inverse * t * t; + const float d = t * t * t; + draw->AddCircleFilled({ + a * start.x + b * control_start.x + c * control_end.x + d * end.x, + a * start.y + b * control_start.y + c * control_end.y + d * end.y, + }, radius, color); + } +} + +void draw_copyable_hash(std::string_view visible, std::string_view full_hash) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.31f, 0.76f, 0.92f, 1.0f)); + ImGui::TextUnformatted(visible.data(), visible.data() + visible.size()); + ImGui::PopStyleColor(); + const bool hovered = ImGui::IsItemHovered(); + const bool clicked = ImGui::IsItemClicked(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Copy: %.*s", static_cast(full_hash.size()), full_hash.data()); + } + if (clicked) copy_to_clipboard(full_hash, "commit hash"); +} + +void draw_jumpable_hash(std::string_view visible, std::string_view full_hash) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.31f, 0.76f, 0.92f, 1.0f)); + ImGui::TextUnformatted(visible.data(), visible.data() + visible.size()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Jump to commit in graph"); + } + if (ImGui::IsItemClicked()) g_pending_commit_jump.assign(full_hash); +} void cancel_inline_branch() { g_inline_branch_repository = nullptr; @@ -140,7 +231,7 @@ void pick_and_open_repository() { izo::DialogOptions options; options.title = "Open Git repository"; if (!repo().path.empty()) options.initialPath = repo().path; - else options.initialPath = std::filesystem::current_path(); + else options.initialPath = izo::GetKnownPath(izo::KnownPath::CurrentDirectory); const izo::DialogResult result = izo::PickFolder(options); if (result.status == izo::DialogStatus::Cancelled) return; @@ -170,13 +261,19 @@ void apply_style(float scale) { ImGuiStyle& style = ImGui::GetStyle(); style = ImGuiStyle(); style.WindowRounding = 0.0f; + style.WindowBorderSize = 0.0f; style.ChildRounding = 0.0f; - style.FrameRounding = 0.0f; - style.PopupRounding = 0.0f; - style.ScrollbarRounding = 0.0f; + style.ChildBorderSize = 0.0f; + style.FrameRounding = 4.0f; + style.FrameBorderSize = 0.0f; + style.PopupRounding = 6.0f; + style.PopupBorderSize = 0.0f; + style.ScrollbarRounding = 6.0f; style.GrabRounding = 0.0f; - style.TabRounding = 0.0f; + style.TabRounding = 6.0f; + style.TabBorderSize = 0.0f; style.TabBarBorderSize = 0.0f; + style.TabBarOverlineSize = 0.0f; style.WindowPadding = {4.0f, 4.0f}; style.FramePadding = {6.0f, 3.0f}; style.CellPadding = {6.0f, 2.0f}; @@ -208,14 +305,14 @@ void apply_style(float scale) { style.Colors[ImGuiCol_Tab] = color(42, 45, 52); style.Colors[ImGuiCol_TabHovered] = color(51, 55, 63); style.Colors[ImGuiCol_TabSelected] = color(51, 55, 63); - style.Colors[ImGuiCol_Separator] = color(55, 59, 67); + style.Colors[ImGuiCol_Separator] = color(55, 59, 67, 100); style.Colors[ImGuiCol_SeparatorHovered] = color(23, 181, 204); style.Colors[ImGuiCol_SeparatorActive] = color(23, 181, 204); style.Colors[ImGuiCol_TableHeaderBg] = color(51, 55, 63); style.Colors[ImGuiCol_TableRowBg] = color(28, 30, 35); style.Colors[ImGuiCol_TableRowBgAlt] = color(28, 30, 35); style.Colors[ImGuiCol_CheckMark] = color(23, 181, 204); - style.Colors[ImGuiCol_ScrollbarBg] = color(28, 30, 35); + style.Colors[ImGuiCol_ScrollbarBg] = color(0, 0, 0, 0); style.Colors[ImGuiCol_ScrollbarGrab] = color(67, 71, 80); style.Colors[ImGuiCol_ScrollbarGrabHovered] = color(82, 87, 97); style.Colors[ImGuiCol_NavHighlight] = color(23, 181, 204); @@ -238,9 +335,11 @@ void load_fonts(float scale) { ImFontConfig icon_config; icon_config.MergeMode = true; icon_config.PixelSnapH = true; - icon_config.GlyphMinAdvanceX = size; + const float icon_size = 14.0f * scale; + icon_config.GlyphMinAdvanceX = icon_size; + icon_config.GlyphOffset.y = 1.0f * scale; io.Fonts->AddFontFromFileTTF( - GITREE_ASSET_DIR "/fa-solid-900.ttf", size, &icon_config, icon_ranges); + GITREE_ASSET_DIR "/fa-solid-900.ttf", icon_size, &icon_config, icon_ranges); static constexpr ImWchar outline_ranges[] = { 0xE900, 0xE900, @@ -257,7 +356,8 @@ void load_fonts(float scale) { apply_style(scale); } -bool sidebar_collapse_row(const char* id, const std::string& label, bool default_open, float reserved_width) { +bool sidebar_collapse_row(const char* id, const std::string& label, bool default_open, + float reserved_width, bool show_chevron = true) { ImGui::PushID(id); const ImGuiID state_id = ImGui::GetID("collapse_state"); ImGuiStorage* storage = ImGui::GetStateStorage(); @@ -274,16 +374,22 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default constexpr ImU32 icon_color = IM_COL32(144, 150, 160, 255); constexpr ImU32 text_color = IM_COL32(207, 211, 218, 255); const float y = minimum.y + (size.y - ImGui::GetFontSize()) * 0.5f; - draw->AddText({minimum.x + ui(3.0f), y}, icon_color, - open ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_RIGHT); + if (show_chevron) + draw->AddText({minimum.x + ui(3.0f), y}, icon_color, + open ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_RIGHT); const size_t divider = label.find(" "); if (divider != std::string::npos) { const std::string icon = label.substr(0, divider); const std::string text = label.substr(divider + 2); - draw->AddText({minimum.x + ui(20.0f), y}, icon_color, icon.c_str()); - draw->AddText({minimum.x + ui(39.0f), y}, text_color, text.c_str()); + const char* rendered_icon = !show_chevron && open && icon == ICON_FA_FOLDER + ? ICON_FA_FOLDER_OPEN : icon.c_str(); + const float icon_x = show_chevron ? 20.0f : 3.0f; + const float text_x = show_chevron ? 39.0f : 23.0f; + draw->AddText({minimum.x + ui(icon_x), y}, icon_color, rendered_icon); + draw->AddText({minimum.x + ui(text_x), y}, text_color, text.c_str()); } else { - draw->AddText({minimum.x + ui(20.0f), y}, text_color, label.c_str()); + draw->AddText({minimum.x + ui(show_chevron ? 20.0f : 3.0f), y}, + text_color, label.c_str()); } if (clicked) { open = !open; @@ -293,7 +399,10 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default return open; } -bool sidebar_item_row(const char* icon, const std::string& text, const std::string& id) { +bool sidebar_item_row(const char* icon, const std::string& text, const std::string& id, + const char* status_icon = nullptr, ImU32 status_color = 0, + ImU32 item_icon_color = IM_COL32(142, 148, 158, 255), + const std::string& trailing_text = {}) { ImGui::PushID(id.c_str()); const bool clicked = ImGui::InvisibleButton("##sidebar_item", {-1, ui(24.0f)}); const ImVec2 minimum = ImGui::GetItemRectMin(); @@ -301,10 +410,23 @@ bool sidebar_item_row(const char* icon, const std::string& text, const std::stri ImDrawList* draw = ImGui::GetWindowDrawList(); if (ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(51, 55, 63, 210)); const float y = minimum.y + (maximum.y - minimum.y - ImGui::GetFontSize()) * 0.5f; - draw->PushClipRect(minimum, maximum, true); - draw->AddText({minimum.x + ui(3.0f), y}, IM_COL32(142, 148, 158, 255), icon); - draw->AddText({minimum.x + ui(23.0f), y}, IM_COL32(205, 209, 216, 255), text.c_str()); + const float trailing_width = trailing_text.empty() ? 0.0f : ImGui::CalcTextSize(trailing_text.c_str()).x; + const float trailing_x = maximum.x - ui(4.0f) - trailing_width; + const float content_right = trailing_text.empty() ? maximum.x : trailing_x - ui(7.0f); + draw->PushClipRect(minimum, {std::max(minimum.x, content_right), maximum.y}, true); + const float content_offset = status_icon ? ui(20.0f) : 0.0f; + if (status_icon) + draw->AddText({minimum.x + ui(3.0f), y}, status_color, status_icon); + draw->AddText({minimum.x + ui(3.0f) + content_offset, y}, + item_icon_color, icon); + draw->AddText({minimum.x + ui(23.0f) + content_offset, y}, + IM_COL32(205, 209, 216, 255), text.c_str()); draw->PopClipRect(); + if (!trailing_text.empty()) { + draw->PushClipRect(minimum, maximum, true); + draw->AddText({trailing_x, y}, IM_COL32(163, 169, 180, 255), trailing_text.c_str()); + draw->PopClipRect(); + } ImGui::PopID(); return clicked; } @@ -352,7 +474,12 @@ bool sidebar_section_header(const char* label, int count, const char* add_toolti if (clicked) { const std::string section = label; if (section.find("LOCAL") != std::string::npos) { - begin_inline_branch(repo().selected_commit >= 0 ? repo().selected_commit : 0); + if (repo().commits.empty()) g_notice = "Create an initial commit before creating a branch"; + else { + const int commit_index = repo().selected_commit >= 0 ? repo().selected_commit : 0; + g_git_target = oid_string(repo().commits[static_cast(commit_index)].oid); + g_branch_create_popup = true; + } } else if (section.find("REMOTE") != std::string::npos) g_remote_add_popup = true; else if (section.find("WORKTREES") != std::string::npos) g_worktree_add_popup = true; @@ -402,7 +529,7 @@ void sidebar_item_context(const std::string& item, SidebarItemKind kind) { if (path.empty() || !izo::OpenPath(path, &error)) g_notice = error; } ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_COPY " Copy name")) ImGui::SetClipboardText(item.c_str()); + if (ImGui::MenuItem(ICON_FA_COPY " Copy name")) copy_to_clipboard(item, "name"); ImGui::EndPopup(); } @@ -412,12 +539,38 @@ void section(const char* label, const std::vector& items, const cha const bool open = sidebar_section_header(label, static_cast(items.size()), add_tooltip, add_notice); if (open && body_height >= ui(1.0f)) { ImGui::BeginChild((std::string(label) + "##body").c_str(), {-1, body_height}); + ImGui::Indent(ui(sidebar_child_indent)); const std::vector snapshot = items; for (const auto& item : snapshot) { if (g_filter[0] && item.find(g_filter.data()) == std::string::npos) continue; - sidebar_item_row(item_icon, item, item); + if (kind == SidebarItemKind::submodule) { + const auto found = repo().submodule_statuses.find(item); + const unsigned int status = found == repo().submodule_statuses.end() ? 0 : found->second; + const bool in_sync = found != repo().submodule_statuses.end() && + (status & GIT_SUBMODULE_STATUS_IN_HEAD) != 0 && + (status & GIT_SUBMODULE_STATUS_IN_WD) != 0 && + GIT_SUBMODULE_STATUS_IS_UNMODIFIED(status); + sidebar_item_row(item_icon, item, item, + in_sync ? ICON_FA_CHECK : ICON_FA_TRIANGLE_EXCLAMATION, + in_sync ? IM_COL32(70, 201, 101, 255) : IM_COL32(230, 165, 72, 255)); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + if (in_sync) + ImGui::SetTooltip("This submodule is in-sync with your repository (%s)", + repo().name.c_str()); + else if ((status & GIT_SUBMODULE_STATUS_WD_UNINITIALIZED) != 0) + ImGui::SetTooltip("This submodule is not initialized (%s)", item.c_str()); + else if (GIT_SUBMODULE_STATUS_IS_WD_DIRTY(status)) + ImGui::SetTooltip("This submodule has local changes (%s)", item.c_str()); + else + ImGui::SetTooltip("This submodule is out-of-sync with your repository (%s)", + repo().name.c_str()); + } + } else { + sidebar_item_row(item_icon, item, item); + } sidebar_item_context(item, kind); } + ImGui::Unindent(ui(sidebar_child_indent)); ImGui::EndChild(); if (resizable) sidebar_section_splitter(label, section_index, maximum_height); } else { @@ -454,28 +607,52 @@ void branch_context(const std::string& branch, bool remote) { g_git_manager->fetch(repo(), slash == std::string::npos ? branch : branch.substr(0, slash), g_notice); } ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_COPY " Copy branch name")) ImGui::SetClipboardText(branch.c_str()); + if (ImGui::MenuItem(ICON_FA_COPY " Copy branch name")) copy_to_clipboard(branch, "branch name"); ImGui::EndPopup(); } +std::string branch_divergence_text(const std::string& branch, bool remote) { + const auto& divergences = remote + ? repo().remote_branch_divergence + : repo().local_branch_divergence; + const auto found = divergences.find(branch); + if (found == divergences.end()) return {}; + + std::string text; + if (found->second.ahead > 0) + text = std::to_string(found->second.ahead) + ICON_FA_ARROW_UP; + if (found->second.behind > 0) { + if (!text.empty()) text += " "; + text += std::to_string(found->second.behind) + ICON_FA_ARROW_DOWN; + } + return text; +} + void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const char* root_group_icon, bool remote, const std::string& id_path = {}, int depth = 0) { + const ImU32 branch_icon_color = remote + ? IM_COL32(88, 94, 105, 255) + : IM_COL32(142, 148, 158, 255); for (const auto& [name, node] : parent.children) { const std::string id = id_path + "/" + name; if (!node.children.empty()) { const char* group_icon = depth == 0 ? root_group_icon : ICON_FA_FOLDER; - const bool open = sidebar_collapse_row(id.c_str(), std::string(group_icon) + " " + name, true, 0); + const bool open = sidebar_collapse_row( + id.c_str(), std::string(group_icon) + " " + name, true, 0, false); if (open) { - ImGui::Indent(ui(17.0f)); + ImGui::Indent(ui(sidebar_nested_indent)); if (node.branch) { - sidebar_item_row(branch_icon, name, "branch" + id); + const std::string divergence = branch_divergence_text(node.full_name, remote); + sidebar_item_row(branch_icon, name, "branch" + id, + nullptr, 0, branch_icon_color, divergence); branch_context(node.full_name, remote); } draw_branch_nodes(node, branch_icon, root_group_icon, remote, id, depth + 1); - ImGui::Unindent(ui(17.0f)); + ImGui::Unindent(ui(sidebar_nested_indent)); } } else { - sidebar_item_row(branch_icon, name, id); + const std::string divergence = branch_divergence_text(node.full_name, remote); + sidebar_item_row(branch_icon, name, id, nullptr, 0, branch_icon_color, divergence); branch_context(node.full_name, remote); } } @@ -492,9 +669,9 @@ void branch_section(const char* label, const std::vector& branches, if (g_filter[0] && branch.find(g_filter.data()) == std::string::npos) continue; add_branch_node(root, branch); } - ImGui::Indent(ui(17.0f)); + ImGui::Indent(ui(sidebar_child_indent)); draw_branch_nodes(root, branch_icon, group_icon, remote, label); - ImGui::Unindent(ui(17.0f)); + ImGui::Unindent(ui(sidebar_child_indent)); ImGui::EndChild(); if (resizable) sidebar_section_splitter(label, section_index, maximum_height); } else { @@ -519,9 +696,9 @@ void draw_sidebar(float width) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {section_spacing.x, 0.0f}); constexpr const char* section_ids[] = { - ICON_FA_HOUSE " LOCAL", + ICON_FA_DESKTOP " LOCAL", ICON_FA_CLOUD " REMOTE", - ICON_FA_DIAGRAM_PROJECT " WORKTREES", + ICON_FA_TREE " WORKTREES", ICON_FA_CUBES " SUBMODULES", }; std::array section_open{}; @@ -561,13 +738,13 @@ void draw_sidebar(float width) { } } - branch_section(ICON_FA_HOUSE " LOCAL", repo().local_branches, ICON_FA_CODE_BRANCH, ICON_FA_FOLDER, + branch_section(ICON_FA_DESKTOP " LOCAL", repo().local_branches, ICON_FA_CODE_BRANCH, ICON_FA_FOLDER, "Create local branch", "Create local branch", false, 0, section_heights[0], maximum_heights[0], section_open[0] && last_open != 0); branch_section(ICON_FA_CLOUD " REMOTE", repo().remote_branches, ICON_FA_CODE_BRANCH, ICON_FA_GLOBE, "Add remote", "Add remote", true, 1, section_heights[1], maximum_heights[1], section_open[1] && last_open != 1); - section(ICON_FA_DIAGRAM_PROJECT " WORKTREES", repo().worktrees, ICON_FA_COMPUTER, + section(ICON_FA_TREE " WORKTREES", repo().worktrees, ICON_FA_COMPUTER, "Add worktree", "Add worktree", SidebarItemKind::worktree, 2, section_heights[2], maximum_heights[2], section_open[2] && last_open != 2); section(ICON_FA_CUBES " SUBMODULES", repo().submodules, ICON_FA_CUBES, @@ -596,31 +773,36 @@ float outline_icon_width(const char* icon) { float ref_badge_width(const RefBadge& badge) { const std::string display_name = ref_display_name(badge); - float width = ImGui::CalcTextSize(display_name.c_str()).x + ui(12.0f); - if (badge.current) width += outline_icon_width(ICON_TB_CHECK) + ui(5.0f); - if (badge.kind == RefKind::local) width += ui(5.0f) + outline_icon_width(ICON_TB_DEVICE_LAPTOP); + float width = ImGui::CalcTextSize(display_name.c_str()).x + ui(14.0f); + if (badge.kind == RefKind::local) + width += outline_icon_width(ICON_TB_CHECK) + outline_icon_width(ICON_TB_DEVICE_LAPTOP) + ui(10.0f); if (badge.kind == RefKind::remote || badge.upstream) width += ui(5.0f) + outline_icon_width(ICON_TB_CLOUD); if (badge.kind == RefKind::tag) width += ui(5.0f) + outline_icon_width(ICON_TB_TAG); return width; } -void draw_ref_badge(const RefBadge& badge, int index, int lane) { +void draw_ref_badge(const RefBadge& badge, int widget_index, int commit_index, int lane) { const std::string display_name = ref_display_name(badge); const bool show_cloud = badge.kind == RefKind::remote || badge.upstream; const bool show_tag = badge.kind == RefKind::tag; - const ImVec2 label_size = ImGui::CalcTextSize(display_name.c_str()); - const ImVec2 chip_size{ref_badge_width(badge), ui(19.0f)}; - ImGui::PushID(index); + const ImVec2 chip_size{ref_badge_width(badge), ui(20.0f)}; + ImGui::PushID(widget_index); ImGui::InvisibleButton("##ref_badge", chip_size); + const bool switchable_branch = badge.kind == RefKind::local && !badge.current; + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + repo().selected_commit = commit_index; + if (switchable_branch && ImGui::IsItemHovered() && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + g_pending_branch_checkout = badge.name; const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); - ImU32 background = GraphRenderer::laneColor(lane, badge.current ? 165 : 90); - if (ImGui::IsItemHovered()) background = GraphRenderer::laneColor(lane, badge.current ? 195 : 125); - draw->AddRectFilled(minimum, maximum, background); - draw->AddRect(minimum, maximum, GraphRenderer::laneColor(lane, badge.current ? 210 : 145)); + ImU32 background = GraphRenderer::laneColor(lane, badge.current ? 230 : 205); + if (ImGui::IsItemHovered()) background = GraphRenderer::laneColor(lane, 245); + const float rounding = ui(4.0f); + draw->AddRectFilled(minimum, maximum, background, rounding); float x = minimum.x + ui(6.0f); const float text_y = minimum.y + (chip_size.y - ImGui::GetFontSize()) * 0.5f; @@ -631,24 +813,56 @@ void draw_ref_badge(const RefBadge& badge, int index, int lane) { draw->AddText(g_outline_icon_font, g_outline_icon_size, {x, icon_y}, color, icon); x += outline_icon_width(icon) + ui(5.0f); }; - if (badge.current) draw_icon(ICON_TB_CHECK); draw->AddText({x, text_y}, color, display_name.c_str()); - x += label_size.x + ui(5.0f); - if (badge.kind == RefKind::local) draw_icon(ICON_TB_DEVICE_LAPTOP); + x += ImGui::CalcTextSize(display_name.c_str()).x + ui(5.0f); if (show_cloud) draw_icon(ICON_TB_CLOUD); + if (badge.kind == RefKind::local) draw_icon(ICON_TB_DEVICE_LAPTOP); if (show_tag) draw_icon(ICON_TB_TAG); + if (badge.current) draw_icon(ICON_TB_CHECK); if (ImGui::BeginPopupContextItem()) { - if (badge.kind == RefKind::local && ImGui::MenuItem(ICON_FA_CODE_BRANCH " Checkout")) - g_git_manager->checkoutBranch(repo(), badge.name, g_notice); - if (ImGui::MenuItem(ICON_FA_COPY " Copy ref name")) ImGui::SetClipboardText(badge.name.c_str()); + if (switchable_branch && ImGui::MenuItem(ICON_FA_CODE_BRANCH " Checkout")) { + g_pending_branch_checkout = badge.name; + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem(ICON_FA_COPY " Copy ref name")) copy_to_clipboard(badge.name, "ref name"); ImGui::EndPopup(); } - if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("%s", badge.name.c_str()); + if (ImGui::IsItemHovered()) { + if (switchable_branch) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + if (switchable_branch) ImGui::SetTooltip("Double-click to checkout %s", badge.name.c_str()); + else if (badge.current) ImGui::SetTooltip("Current branch: %s", badge.name.c_str()); + else ImGui::SetTooltip("%s", badge.name.c_str()); + } + } ImGui::PopID(); } void draw_commit_table() { + const ScopedUiScale table_scale(1.10f); + if (!g_pending_commit_jump.empty()) { + git_oid target{}; + const bool valid_target = git_oid_fromstr(&target, g_pending_commit_jump.c_str()) == 0; + g_pending_commit_jump.clear(); + auto find_target = [&]() { + return std::find_if(repo().commits.begin(), repo().commits.end(), [&](const CommitInfo& commit) { + return valid_target && git_oid_equal(&commit.oid, &target) != 0; + }); + }; + auto target_commit = find_target(); + while (valid_target && target_commit == repo().commits.end() && !repo().history_exhausted) { + if (!g_git_manager->loadMoreCommits(repo(), 500, g_notice)) break; + target_commit = find_target(); + } + if (target_commit != repo().commits.end()) { + repo().selected_commit = static_cast(std::distance(repo().commits.begin(), target_commit)); + repo().scroll_to_commit = repo().selected_commit; + } else if (valid_target) { + g_notice = "Commit is not available in the graph"; + } + } + const auto commit_visible = [](const CommitInfo& commit) { if (!g_filter[0]) return true; if (commit.summary.find(g_filter.data()) != std::string::npos) return true; @@ -664,10 +878,16 @@ void draw_commit_table() { width += ref_badge_width(badge) + (width > 0.0f ? ui(4.0f) : 0.0f); widest_reference_row = std::max(widest_reference_row, width); } - const float maximum_reference_width = std::max(ui(145.0f), - std::min(ui(320.0f), ImGui::GetContentRegionAvail().x * 0.36f)); + const float available_table_width = ImGui::GetContentRegionAvail().x; + const float date_width = std::clamp(available_table_width * 0.20f, ui(130.0f), ui(175.0f)); const float reference_width = std::clamp( - widest_reference_row + ui(12.0f), ui(145.0f), maximum_reference_width); + widest_reference_row + ui(12.0f), ui(120.0f), available_table_width * 0.27f); + const float desired_graph_width = GraphRenderer::requiredWidth(repo().commits, g_ui_scale) + + (repo().working_files.empty() ? 0.0f : ui(22.0f)); + const float graph_width = std::min(desired_graph_width, + std::max(ui(90.0f), available_table_width - reference_width - date_width - ui(170.0f))); + const float message_width = std::max(ui(120.0f), + available_table_width - reference_width - graph_width - date_width); const float chip_line_width = std::max(ui(40.0f), reference_width - ui(12.0f)); std::vector row_heights(repo().commits.size(), 0.0f); std::unordered_map commit_rows; @@ -706,8 +926,9 @@ void draw_commit_table() { row_heights[index] = std::max(row_heights[index], ui(30.0f)); } - ImGuiTableFlags flags = ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | - ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp; + ImGuiTableFlags flags = ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoSavedSettings; ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, {ImGui::GetStyle().CellPadding.x, ui(1.0f)}); if (!ImGui::BeginTable("commits", 4, flags, {-1, -1})) { ImGui::PopStyleVar(); @@ -715,38 +936,97 @@ void draw_commit_table() { } ImGui::TableSetupScrollFreeze(0, 1); const GraphRenderer graph(g_ui_scale); - const float graph_width = GraphRenderer::requiredWidth(repo().commits, g_ui_scale); ImGui::TableSetupColumn("BRANCH / TAG", ImGuiTableColumnFlags_WidthFixed, reference_width); ImGui::TableSetupColumn("GRAPH", ImGuiTableColumnFlags_WidthFixed, graph_width); - ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, ui(180.0f)); + ImGui::TableSetupColumn("COMMIT MESSAGE", ImGuiTableColumnFlags_WidthFixed, message_width); + ImGui::TableSetupColumn("COMMIT DATE / TIME", ImGuiTableColumnFlags_WidthFixed, date_width); ImGui::TableHeadersRow(); + const auto graph_row_interaction = [&](const char* id, bool selected, float row_height) { + const ImVec2 cursor = ImGui::GetCursorScreenPos(); + const float row_left = cursor.x - ImGui::GetStyle().CellPadding.x; + const float row_top = cursor.y - ImGui::GetStyle().CellPadding.y; + const float graph_right = row_left + reference_width + graph_width; + const ImVec2 mouse = ImGui::GetIO().MousePos; + const bool hovered = ImGui::IsWindowHovered() && mouse.x >= row_left && mouse.x < graph_right && + mouse.y >= row_top && mouse.y < row_top + row_height; + if (selected || hovered) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + draw->PushClipRectFullScreen(); + draw->AddRectFilled({row_left, row_top}, {graph_right, row_top + row_height}, + selected ? IM_COL32(45, 58, 84, 155) : IM_COL32(70, 77, 90, 55)); + draw->PopClipRect(); + } + ImGui::InvisibleButton(id, {-1, row_height}); + return ImGui::IsItemClicked(ImGuiMouseButton_Left) || + (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)); + }; if (!repo().working_files.empty()) { ImGui::TableNextRow(0, ui(24.0f)); ImGui::TableSetColumnIndex(0); - if (ImGui::Selectable("##working_tree", repo().selected_commit == -1, - ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) + if (graph_row_interaction("##working_tree", repo().selected_commit == -1, ui(24.0f))) repo().selected_commit = -1; ImGui::TableSetColumnIndex(1); const ImVec2 position = ImGui::GetCursorScreenPos(); ImDrawList* draw = ImGui::GetWindowDrawList(); - const ImVec2 center{position.x + ui(15), position.y + ui(10.0f)}; + const int first_lane = repo().commits.empty() ? 0 : repo().commits.front().lane; + const float lane_x = position.x + ui(17.0f) + ui(22.0f) * first_lane; + const ImVec2 center{lane_x + ui(22.0f), position.y + ui(10.0f)}; + if (!repo().commits.empty()) { + const float next_center_y = position.y + ui(24.0f) + + std::max(ui(1.0f), row_heights.front() - ImGui::GetStyle().CellPadding.y * 2.0f) * 0.5f; + const ImVec2 end{lane_x, next_center_y}; + const float turn_y = center.y + (end.y - center.y) * 0.55f; + draw->PushClipRectFullScreen(); + draw_dotted_bezier(draw, center, {center.x, turn_y}, {end.x, turn_y}, end, + IM_COL32(23, 181, 204, 235), ui(1.15f), ui(4.2f)); + draw->PopClipRect(); + } draw->AddCircle(center, ui(6), IM_COL32(23, 181, 204, 220), 12, ui(1.5f)); ImGui::Dummy({0, ui(20)}); ImGui::TableSetColumnIndex(2); - const std::string change_count = std::string(ICON_FA_CIRCLE_PLUS " ") + - std::to_string(repo().working_files.size()); - const float count_width = ImGui::CalcTextSize(change_count.c_str()).x; + int modified_count = 0; + int added_count = 0; + int deleted_count = 0; + for (const WorkingFile& file : repo().working_files) { + if (file.kind == FileChangeKind::added) ++added_count; + else if (file.kind == FileChangeKind::deleted) ++deleted_count; + else ++modified_count; + } + const std::string modified_text = std::string(ICON_FA_PEN " ") + std::to_string(modified_count); + const std::string added_text = std::string(ICON_FA_PLUS " ") + std::to_string(added_count); + const std::string deleted_text = std::string(ICON_FA_MINUS " ") + std::to_string(deleted_count); + float count_width = 0.0f; + int visible_counts = 0; + const auto measure_count = [&](int count, const std::string& text) { + if (!count) return; + if (visible_counts++) count_width += ui(10.0f); + count_width += ImGui::CalcTextSize(text.c_str()).x; + }; + measure_count(modified_count, modified_text); + measure_count(added_count, added_text); + measure_count(deleted_count, deleted_text); const float pending_width = std::clamp( - ImGui::GetContentRegionAvail().x - count_width - ui(12.0f), ui(90.0f), ui(180.0f)); + ImGui::GetContentRegionAvail().x - count_width - ui(12.0f), ui(78.0f), ui(105.0f)); ImGui::SetNextItemWidth(pending_width); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.075f, 0.085f, 0.10f, 0.82f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(0.10f, 0.115f, 0.14f, 0.92f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(0.11f, 0.125f, 0.15f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(3.0f)); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ui(7.0f), ui(1.0f)}); ImGui::InputTextWithHint("##pending_commit_summary", "// WIP", g_commit_summary.data(), g_commit_summary.size()); - ImGui::PopStyleVar(); - ImGui::SameLine(0, ui(8.0f)); - ImGui::TextColored(ImVec4(0.38f, 0.84f, 0.48f, 1), "%s", - change_count.c_str()); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(3); + bool first_count = true; + const auto draw_count = [&](int count, FileChangeKind kind, const std::string& text) { + if (!count) return; + ImGui::SameLine(0, first_count ? ui(8.0f) : ui(10.0f)); + ImGui::TextColored(change_color(kind), "%s", text.c_str()); + first_count = false; + }; + draw_count(modified_count, FileChangeKind::modified, modified_text); + draw_count(added_count, FileChangeKind::added, added_text); + draw_count(deleted_count, FileChangeKind::deleted, deleted_text); } bool submit_inline_branch = false; for (int i = 0; i < static_cast(repo().commits.size()); ++i) { @@ -755,19 +1035,21 @@ void draw_commit_table() { const float row_height = row_heights[static_cast(i)]; ImGui::TableNextRow(0, row_height); ImGui::TableSetColumnIndex(0); + if (repo().scroll_to_commit == i) { + ImGui::SetScrollFromPosY(ImGui::GetCursorScreenPos().y, 0.5f); + repo().scroll_to_commit = -1; + } if (!ImGui::IsRectVisible({ui(1.0f), row_height})) continue; const ImVec2 reference_origin = ImGui::GetCursorScreenPos(); std::string row_id = "##commit" + std::to_string(i); - if (ImGui::Selectable(row_id.c_str(), repo().selected_commit == i, - ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) repo().selected_commit = i; + if (graph_row_interaction(row_id.c_str(), repo().selected_commit == i, row_height)) + repo().selected_commit = i; if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem(ICON_FA_COPY " Copy commit hash")) { - char oid[GIT_OID_SHA1_HEXSIZE + 1]{}; - git_oid_tostr(oid, sizeof(oid), &commit.oid); - ImGui::SetClipboardText(oid); + copy_to_clipboard(oid_string(commit.oid), "commit hash"); } if (ImGui::MenuItem(ICON_FA_COPY " Copy commit message")) - ImGui::SetClipboardText(commit.summary.c_str()); + copy_to_clipboard(commit.summary, "commit message"); ImGui::Separator(); if (ImGui::MenuItem(ICON_FA_TAG " Create tag here")) { char target[GIT_OID_SHA1_HEXSIZE + 1]{}; @@ -800,11 +1082,6 @@ void draw_commit_table() { ImGui::PopStyleVar(2); ImGui::PopStyleColor(2); if (ImGui::IsKeyPressed(ImGuiKey_Escape)) cancel_inline_branch(); - } else if (!commit.refs.empty()) { - const float connector_y = reference_origin.y + row_height * 0.5f; - ImGui::GetWindowDrawList()->AddLine( - {reference_origin.x, connector_y}, {chip_right + ui(6.0f), connector_y}, - GraphRenderer::laneColor(commit.lane, 120), ui(1.0f)); } for (int ref_index = 0; !editing_branch && ref_index < static_cast(commit.refs.size()); ++ref_index) { const float badge_width = ref_badge_width(commit.refs[ref_index]); @@ -813,18 +1090,26 @@ void draw_commit_table() { chip_y += ui(23.0f); } ImGui::SetCursorScreenPos({chip_x, chip_y}); - draw_ref_badge(commit.refs[ref_index], i * 1000 + ref_index, commit.lane); + draw_ref_badge(commit.refs[ref_index], i * 1000 + ref_index, i, commit.graph_color); chip_x += badge_width + ui(4.0f); } ImGui::TableSetColumnIndex(1); - graph.drawRow(i, commit, repo().commits, row_heights, parent_rows, g_avatar_cache); + graph.drawRow(i, commit, repo().commits, row_heights, parent_rows, g_avatar_cache, + nullptr); ImGui::TableSetColumnIndex(2); ImGui::PushID(i); const ImVec2 message_position = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##commit_message", {-1, std::max(ImGui::GetTextLineHeight(), row_height - ui(2.0f))}); if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) repo().selected_commit = i; - ImGui::GetWindowDrawList()->AddText(message_position, ImGui::GetColorU32(ImGuiCol_Text), - commit.summary.c_str()); + ImDrawList* message_draw = ImGui::GetWindowDrawList(); + message_draw->PushClipRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), true); + message_draw->AddText(message_position, ImGui::GetColorU32(ImGuiCol_Text), commit.summary.c_str()); + if (!commit.description.empty()) { + const float summary_width = ImGui::CalcTextSize(commit.summary.c_str()).x; + message_draw->AddText({message_position.x + summary_width + ui(12.0f), message_position.y}, + IM_COL32(143, 149, 160, 255), commit.description.c_str()); + } + message_draw->PopClipRect(); ImGui::PopID(); ImGui::TableSetColumnIndex(3); ImGui::TextDisabled("%s", commit.date.c_str()); @@ -833,6 +1118,12 @@ void draw_commit_table() { ImGui::GetScrollMaxY() - ImGui::GetScrollY() < ImGui::GetWindowHeight() * 1.5f; ImGui::EndTable(); ImGui::PopStyleVar(); + if (!g_pending_branch_checkout.empty()) { + const std::string branch = std::move(g_pending_branch_checkout); + g_pending_branch_checkout.clear(); + g_git_manager->checkoutBranch(repo(), branch, g_notice); + return; + } if (load_more_history) g_git_manager->loadMoreCommits(repo(), 500, g_notice); if (submit_inline_branch && g_inline_branch_name[0] != '\0') { const std::string name = g_inline_branch_name.data(); @@ -843,26 +1134,31 @@ void draw_commit_table() { } const char* change_icon(FileChangeKind kind) { - if (kind == FileChangeKind::added) return ICON_FA_CIRCLE_PLUS; + if (kind == FileChangeKind::added) return ICON_FA_PLUS; if (kind == FileChangeKind::deleted) return ICON_FA_MINUS; if (kind == FileChangeKind::renamed) return ICON_FA_ARROW_RIGHT_ARROW_LEFT; - return ICON_FA_PEN; + if (kind == FileChangeKind::modified) return ICON_FA_PEN; + return ICON_FA_FILE; } ImVec4 change_color(FileChangeKind kind) { if (kind == FileChangeKind::added) return {0.31f, 0.82f, 0.43f, 1}; if (kind == FileChangeKind::deleted) return {0.91f, 0.35f, 0.35f, 1}; if (kind == FileChangeKind::renamed) return {0.38f, 0.67f, 0.92f, 1}; - return {0.94f, 0.66f, 0.25f, 1}; + if (kind == FileChangeKind::modified) return {0.94f, 0.66f, 0.25f, 1}; + return {0.58f, 0.61f, 0.67f, 1}; } void draw_file_row(const std::string& path, FileChangeKind kind, int id, - bool working_file = false, bool staged = false, const std::string& action_path = {}) { + bool working_file = false, bool staged = false, const std::string& action_path = {}, + const std::string& commit_id = {}) { ImGui::PushID(id); ImGui::InvisibleButton("##file", {-1, ui(25.0f)}); const std::string& git_path = action_path.empty() ? path : action_path; - if (working_file && ImGui::IsItemClicked(ImGuiMouseButton_Left)) - g_diff_viewer.open(repo(), *g_git_manager, git_path, staged, g_notice); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + if (commit_id.empty()) g_diff_viewer.open(repo(), *g_git_manager, git_path, staged, g_notice); + else g_diff_viewer.openCommit(repo(), *g_git_manager, git_path, commit_id, g_notice); + } const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); @@ -871,52 +1167,75 @@ void draw_file_row(const std::string& path, FileChangeKind kind, int id, draw->AddText({minimum.x + ui(4.0f), y}, ImGui::ColorConvertFloat4ToU32(change_color(kind)), change_icon(kind)); draw->AddText({minimum.x + ui(20.0f), y}, IM_COL32(205, 209, 216, 255), path.c_str()); if (ImGui::BeginPopupContextItem()) { - if (working_file && !staged && ImGui::MenuItem(ICON_FA_CIRCLE_PLUS " Stage file")) + if (working_file && !staged && ImGui::MenuItem(ICON_FA_PLUS " Stage file")) g_git_manager->stageFile(repo(), git_path, g_notice); if (working_file && staged && ImGui::MenuItem(ICON_FA_MINUS " Unstage file")) g_git_manager->unstageFile(repo(), git_path, g_notice); if (working_file && !staged && ImGui::MenuItem(ICON_FA_TRASH_CAN " Discard changes")) g_git_manager->discardFile(repo(), git_path, g_notice); if (working_file) ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) ImGui::SetClipboardText(path.c_str()); + if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) copy_to_clipboard(path, "path"); ImGui::EndPopup(); } ImGui::PopID(); } void draw_file_toolbar(bool show_view_all) { - ImGui::TextDisabled(ICON_FA_ARROW_DOWN_A_Z); - const float controls_width = ui(show_view_all ? 225.0f : 227.0f); + if (show_view_all && g_view_all_files) g_file_view_mode = FileViewMode::tree; + if (ImGui::SmallButton(g_file_sort_ascending ? ICON_FA_ARROW_DOWN_A_Z : ICON_FA_ARROW_DOWN_Z_A)) + g_file_sort_ascending = !g_file_sort_ascending; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("Sort %s", g_file_sort_ascending ? "Z to A" : "A to Z"); + const bool show_path = !show_view_all || !g_view_all_files; + const float controls_width = ui(show_view_all ? (show_path ? 225.0f : 190.0f) : 227.0f); ImGui::SameLine(std::max(ImGui::GetCursorPosX() + ui(8), ImGui::GetWindowWidth() - controls_width)); - const bool path_selected = g_file_view_mode == FileViewMode::path; - if (path_selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.28f, 0.46f, 1)); - if (ImGui::Button(ICON_FA_BARS " Path")) g_file_view_mode = FileViewMode::path; - if (path_selected) ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); + if (show_path) { + const bool path_selected = g_file_view_mode == FileViewMode::path; + if (path_selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.28f, 0.46f, 1)); + if (ImGui::Button(ICON_FA_BARS " Path")) g_file_view_mode = FileViewMode::path; + if (path_selected) ImGui::PopStyleColor(); + ImGui::SameLine(0, 0); + } const bool tree_selected = g_file_view_mode == FileViewMode::tree; if (tree_selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.28f, 0.46f, 1)); if (ImGui::Button(ICON_FA_FOLDER_TREE " Tree")) g_file_view_mode = FileViewMode::tree; if (tree_selected) ImGui::PopStyleColor(); if (show_view_all) { ImGui::SameLine(); - ImGui::Checkbox("View all files", &g_view_all_files); + if (ImGui::Checkbox("View all files", &g_view_all_files) && g_view_all_files) + g_file_view_mode = FileViewMode::tree; } } +bool file_path_less(std::string_view left, std::string_view right) { + const bool folded_less = std::lexicographical_compare(left.begin(), left.end(), right.begin(), right.end(), + [](unsigned char lhs, unsigned char rhs) { return std::tolower(lhs) < std::tolower(rhs); }); + const bool folded_greater = std::lexicographical_compare(right.begin(), right.end(), left.begin(), left.end(), + [](unsigned char lhs, unsigned char rhs) { return std::tolower(lhs) < std::tolower(rhs); }); + return folded_less || (!folded_greater && left < right); +} + template -void draw_files(const std::vector& files, bool staged_filter = false, bool filter_by_stage = false) { +void draw_files(const std::vector& files, bool staged_filter = false, + bool filter_by_stage = false, const std::string& commit_id = {}) { // Git actions reload repository data immediately, so render from a stable snapshot. - const std::vector snapshot = files; + std::vector snapshot = files; + std::stable_sort(snapshot.begin(), snapshot.end(), [](const File& left, const File& right) { + return g_file_sort_ascending ? file_path_less(left.path, right.path) : file_path_less(right.path, left.path); + }); if (g_file_view_mode == FileViewMode::path) { int id = 0; for (const auto& file : snapshot) { if constexpr (requires { file.staged; }) if (filter_by_stage && file.staged != staged_filter) continue; if constexpr (requires { file.staged; }) draw_file_row(file.path, file.kind, id++, true, file.staged); - else draw_file_row(file.path, file.kind, id++); + else draw_file_row(file.path, file.kind, id++, false, false, {}, commit_id); } return; } - std::map> groups; + const auto group_compare = [](const std::string& left, const std::string& right) { + return g_file_sort_ascending ? file_path_less(left, right) : file_path_less(right, left); + }; + std::map, decltype(group_compare)> groups(group_compare); for (const auto& file : snapshot) { if constexpr (requires { file.staged; }) if (filter_by_stage && file.staged != staged_filter) continue; const size_t slash = file.path.find('/'); @@ -926,13 +1245,15 @@ void draw_files(const std::vector& files, bool staged_filter = false, bool int id = 0; for (const auto& [group, entries] : groups) { if (ImGui::TreeNodeEx((std::string(ICON_FA_FOLDER) + " " + group).c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(ui(12.0f)); for (const File* file : entries) { const size_t slash = file->path.find('/'); const std::string display = slash == std::string::npos ? file->path : file->path.substr(slash + 1); if constexpr (requires { file->staged; }) draw_file_row(display, file->kind, id++, true, file->staged, file->path); - else draw_file_row(display, file->kind, id++); + else draw_file_row(display, file->kind, id++, false, false, file->path, commit_id); } + ImGui::Unindent(ui(12.0f)); ImGui::TreePop(); } } @@ -1087,24 +1408,37 @@ void draw_working_details() { void draw_commit_details() { const int selected = std::clamp(repo().selected_commit, 0, static_cast(repo().commits.size() - 1)); g_git_manager->loadCommitChanges(repo(), selected, g_notice); - const auto& commit = repo().commits[selected]; + auto& commit = repo().commits[selected]; ImGui::TextDisabled("commit:"); ImGui::SameLine(); - ImGui::Text("%s", commit.short_id.c_str()); + const std::string commit_hash = oid_string(commit.oid); + draw_copyable_hash(commit.short_id, commit_hash); ImGui::Separator(); - ImGui::BeginChild("message_card", {-1, ui(125)}, ImGuiChildFlags_Borders); + const float maximum_message_height = std::clamp( + ImGui::GetContentRegionAvail().y / g_ui_scale - 190.0f, 72.0f, 320.0f); + g_commit_message_height = std::min(g_commit_message_height, maximum_message_height); + ImGui::BeginChild("message_card", {-1, ui(g_commit_message_height)}, ImGuiChildFlags_Borders); ImGui::PushTextWrapPos(); ImGui::Text("%s", commit.summary.c_str()); ImGui::PopTextWrapPos(); ImGui::EndChild(); - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ui(80)) * 0.5f); - const ImVec2 handle = ImGui::GetCursorScreenPos(); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ui(90.0f)) * 0.5f); + ImGui::InvisibleButton("##commit_message_resize", {ui(90.0f), ui(10.0f)}); + const bool resize_active = ImGui::IsItemActive(); + const bool resize_hovered = ImGui::IsItemHovered(); + if (resize_active || resize_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + if (resize_active) { + g_commit_message_height = std::clamp( + g_commit_message_height + ImGui::GetIO().MouseDelta.y / g_ui_scale, + 72.0f, maximum_message_height); + } + const ImVec2 handle_minimum = ImGui::GetItemRectMin(); + const ImVec2 handle_maximum = ImGui::GetItemRectMax(); + const float handle_y = (handle_minimum.y + handle_maximum.y) * 0.5f; ImGui::GetWindowDrawList()->AddLine( - handle, - {handle.x + ui(80), handle.y}, - IM_COL32(72, 80, 94, 255), - ui(3)); - ImGui::Dummy({ui(80), ui(6)}); + {handle_minimum.x, handle_y}, {handle_maximum.x, handle_y}, + resize_active || resize_hovered ? IM_COL32(23, 181, 204, 255) : IM_COL32(72, 80, 94, 255), + ui(3.0f)); const unsigned int author_texture = g_avatar_cache ? g_avatar_cache->textureFor(commit.email) : 0; if (author_texture) { @@ -1116,10 +1450,11 @@ void draw_commit_details() { ImGui::TextDisabled("authored %s", commit.date.c_str()); ImGui::EndGroup(); if (!commit.parent_ids.empty()) { - char parent[GIT_OID_SHA1_HEXSIZE + 1]{}; - git_oid_tostr(parent, sizeof(parent), &commit.parent_ids.front()); + const std::string parent_hash = oid_string(commit.parent_ids.front()); ImGui::SameLine(std::max(ui(180), ImGui::GetWindowWidth() - ui(95))); - ImGui::TextDisabled("parent: %.7s", parent); + ImGui::TextDisabled("parent:"); + ImGui::SameLine(0, ui(4.0f)); + draw_jumpable_hash(std::string_view(parent_hash).substr(0, 7), parent_hash); } ImGui::Dummy({0, ui(14)}); int added = 0, modified = 0, deleted = 0; @@ -1129,13 +1464,16 @@ void draw_commit_details() { else ++modified; } if (modified) ImGui::TextColored(change_color(FileChangeKind::modified), ICON_FA_PEN " %d modified", modified); - if (added) { if (modified) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::added), ICON_FA_CIRCLE_PLUS " %d added", added); } + if (added) { if (modified) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::added), ICON_FA_PLUS " %d added", added); } if (deleted) { if (modified || added) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::deleted), ICON_FA_MINUS " %d deleted", deleted); } draw_file_toolbar(true); ImGui::Separator(); if (g_file_view_mode == FileViewMode::tree) ImGui::TextUnformatted("Collapse All"); ImGui::BeginChild("changed_files", {-1, -1}); - draw_files(commit.changed_files); + if (g_view_all_files) g_git_manager->loadCommitFiles(repo(), selected, g_notice); + const auto& displayed_files = g_view_all_files && commit.all_files_loaded + ? commit.all_files : commit.changed_files; + draw_files(displayed_files, false, false, oid_string(commit.oid)); ImGui::EndChild(); } @@ -1156,7 +1494,7 @@ bool toolbar_action(const char* id, const char* label, const char* icon, const c bool* dropdown_clicked = nullptr) { ImGui::PushID(id); if (!enabled) ImGui::BeginDisabled(); - const bool raw_clicked = ImGui::InvisibleButton("##action", {ui(logical_width), ui(38.0f)}); + const bool raw_clicked = ImGui::InvisibleButton("##action", {ui(logical_width), ui(46.0f)}); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); const bool clicked_dropdown = raw_clicked && dropdown && @@ -1169,12 +1507,12 @@ bool toolbar_action(const char* id, const char* label, const char* icon, const c const ImU32 text_color = enabled ? IM_COL32(218, 221, 226, 255) : IM_COL32(139, 144, 153, 255); const ImVec2 label_size = ImGui::CalcTextSize(label); const ImVec2 icon_size = ImGui::CalcTextSize(icon); - draw->AddText({minimum.x + (maximum.x - minimum.x - label_size.x) * 0.5f, minimum.y}, + draw->AddText({minimum.x + (maximum.x - minimum.x - label_size.x) * 0.5f, minimum.y + ui(3.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({icon_x, minimum.y + ui(19.0f)}, text_color, icon); + draw->AddText({icon_x, minimum.y + ui(25.0f)}, text_color, icon); if (dropdown) - draw->AddText({icon_x + icon_size.x + ui(7.0f), minimum.y + ui(17.0f)}, text_color, ICON_FA_CARET_DOWN); + draw->AddText({icon_x + icon_size.x + ui(7.0f), minimum.y + ui(23.0f)}, text_color, ICON_FA_CARET_DOWN); if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { ImGui::BeginTooltip(); ImGui::TextUnformatted(tooltip); @@ -1220,12 +1558,12 @@ void draw_pull_options() { bool toolbar_selector(const char* id, const char* label, const std::string& value, float logical_width, bool trailing_arrow = false) { ImGui::PushID(id); - const bool clicked = ImGui::InvisibleButton("##selector", {ui(logical_width), ui(38.0f)}); + const bool clicked = ImGui::InvisibleButton("##selector", {ui(logical_width), ui(46.0f)}); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); if (ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(62, 66, 75, 200)); - draw->AddText({minimum.x + ui(8), minimum.y}, IM_COL32(177, 182, 190, 255), label); + draw->AddText({minimum.x + ui(8), minimum.y + ui(3.0f)}, IM_COL32(177, 182, 190, 255), label); const float value_width = maximum.x - minimum.x - ui(34.0f); std::string displayed = value; if (ImGui::CalcTextSize(displayed.c_str()).x > value_width) { @@ -1240,8 +1578,8 @@ bool toolbar_selector(const char* id, const char* label, const std::string& valu } displayed += ellipsis; } - draw->AddText({minimum.x + ui(8), minimum.y + ui(18)}, IM_COL32(226, 229, 233, 255), displayed.c_str()); - draw->AddText({maximum.x - ui(18), minimum.y + ui(18)}, IM_COL32(160, 165, 174, 255), + draw->AddText({minimum.x + ui(8), minimum.y + ui(25.0f)}, IM_COL32(226, 229, 233, 255), displayed.c_str()); + draw->AddText({maximum.x - ui(18), minimum.y + ui(25.0f)}, IM_COL32(160, 165, 174, 255), trailing_arrow ? ICON_FA_ANGLE_RIGHT : ICON_FA_CARET_DOWN); if (displayed != value && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("%s", value.c_str()); @@ -1265,6 +1603,12 @@ const char* application_icon(ExternalApplicationId application) { return ICON_FA_ARROW_UP_RIGHT_FROM_SQUARE; } +unsigned int application_icon_texture(ExternalApplicationId application) { + if (!g_application_manager || !g_application_icon_cache) return 0; + const ExternalApplication* details = g_application_manager->application(application); + return details ? g_application_icon_cache->textureFor(details->executable) : 0; +} + void open_repository_in(ExternalApplicationId application) { if (!g_application_manager) return; g_open_in_application = application; @@ -1273,41 +1617,103 @@ void open_repository_in(ExternalApplicationId application) { void draw_open_in_button() { if (!g_application_manager) return; - constexpr float button_width = 58.0f; - ImGui::SetCursorPos({ImGui::GetWindowWidth() - ui(button_width + 7.0f), ui(5.0f)}); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {0, 0}); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(5.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, ui(1.0f)); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.14f, 0.17f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.21f, 0.25f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.25f, 0.28f, 0.33f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.20f, 0.68f, 0.95f, 1.0f)); - if (ImGui::Button((std::string(application_icon(g_open_in_application)) + "##open_in").c_str(), - {ui(37.0f), ui(31.0f)})) - open_repository_in(g_open_in_application); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("Open repository"); - ImGui::SameLine(0, 0); - ImGui::PopStyleColor(); - if (ImGui::Button(ICON_FA_CHEVRON_DOWN "##open_in_menu", {ui(21.0f), ui(31.0f)})) - ImGui::OpenPopup("open_repository_in"); - ImGui::PopStyleColor(3); - ImGui::PopStyleVar(3); + constexpr float button_width = 76.0f; + constexpr float primary_width = 52.0f; + constexpr float menu_width = button_width - primary_width; + ImGui::SetCursorPos({ImGui::GetWindowWidth() - ui(button_width + 7.0f), ui(10.0f)}); + const ImVec2 open_button_size{ui(button_width), ui(30.0f)}; + ImGui::InvisibleButton("##open_in", open_button_size); + const bool open_hovered = ImGui::IsItemHovered(); + const bool open_clicked = ImGui::IsItemClicked(); + const ImVec2 open_minimum = ImGui::GetItemRectMin(); + const ImVec2 open_maximum = ImGui::GetItemRectMax(); + const float divider_x = open_minimum.x + ui(primary_width); + const bool menu_segment = open_hovered && ImGui::GetIO().MousePos.x >= divider_x; + ImDrawList* open_draw = ImGui::GetWindowDrawList(); + const float rounding = ui(7.0f); + open_draw->AddRectFilled(open_minimum, open_maximum, IM_COL32(28, 32, 38, 255), rounding); + if (open_hovered) { + if (menu_segment) + open_draw->AddRectFilled({divider_x, open_minimum.y}, open_maximum, + IM_COL32(45, 51, 60, 255), rounding, ImDrawFlags_RoundCornersRight); + else + open_draw->AddRectFilled(open_minimum, {divider_x, open_maximum.y}, + IM_COL32(45, 51, 60, 255), rounding, ImDrawFlags_RoundCornersLeft); + } + open_draw->AddRect(open_minimum, open_maximum, IM_COL32(56, 62, 72, 255), rounding, 0, ui(1.0f)); + const unsigned int selected_texture = application_icon_texture(g_open_in_application); + if (selected_texture) { + const float icon_size = ui(18.0f); + const ImVec2 icon_minimum{ + open_minimum.x + (ui(primary_width) - icon_size) * 0.5f, + open_minimum.y + (open_button_size.y - icon_size) * 0.5f, + }; + open_draw->AddImage(ImTextureRef(static_cast(selected_texture)), icon_minimum, + {icon_minimum.x + icon_size, icon_minimum.y + icon_size}); + } else { + const char* fallback = application_icon(g_open_in_application); + const ImVec2 icon_size = ImGui::CalcTextSize(fallback); + open_draw->AddText({open_minimum.x + (ui(primary_width) - icon_size.x) * 0.5f, + open_minimum.y + (open_button_size.y - icon_size.y) * 0.5f}, + IM_COL32(51, 173, 242, 255), fallback); + } + const ImVec2 chevron_size = ImGui::CalcTextSize(ICON_FA_CHEVRON_DOWN); + open_draw->AddText({divider_x + (ui(menu_width) - chevron_size.x) * 0.5f, + open_minimum.y + (open_button_size.y - chevron_size.y) * 0.5f}, + IM_COL32(177, 184, 194, 255), ICON_FA_CHEVRON_DOWN); + if (open_clicked) { + if (menu_segment) ImGui::OpenPopup("open_repository_in"); + else open_repository_in(g_open_in_application); + } + if (open_hovered && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + const ExternalApplication* application = g_application_manager->application(g_open_in_application); + if (menu_segment) ImGui::SetTooltip("Choose application"); + else ImGui::SetTooltip("Open in %s", application ? application->name.c_str() : "application"); + } - ImGui::SetNextWindowSize({ui(230.0f), 0}, ImGuiCond_Appearing); - if (ImGui::BeginPopup("open_repository_in")) { - ImGui::TextDisabled("OPEN REPOSITORY IN"); - ImGui::Separator(); + const int available_applications = static_cast(std::count_if( + g_application_manager->applications().begin(), g_application_manager->applications().end(), + [](const ExternalApplication& application) { return application.available; })); + const float popup_width = ui(200.0f); + const float popup_height = ui(available_applications > 0 + ? 12.0f + available_applications * 28.0f : 48.0f); + ImGui::SetNextWindowSize({popup_width, popup_height}, ImGuiCond_Always); + ImGui::SetNextWindowPos( + {open_maximum.x - popup_width, open_maximum.y + ui(4.0f)}, ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, ui(8.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(6.0f), ui(6.0f)}); + const bool popup_open = ImGui::BeginPopup("open_repository_in"); + ImGui::PopStyleVar(2); + if (popup_open) { + if (available_applications == 0) { + ImGui::TextDisabled("No applications found"); + } for (const ExternalApplication& application : g_application_manager->applications()) { + if (!application.available) continue; ImGui::PushID(static_cast(application.id)); - ImGui::BeginDisabled(!application.available); - const std::string label = std::string(application_icon(application.id)) + " " + application.name; - if (ImGui::MenuItem(label.c_str(), nullptr, application.id == g_open_in_application)) { + const ImVec2 row_minimum = ImGui::GetCursorScreenPos(); + const bool selected = application.id == g_open_in_application; + const bool clicked = ImGui::Selectable("##application", selected, + ImGuiSelectableFlags_None, {popup_width - ui(12.0f), ui(28.0f)}); + const ImVec2 row_maximum = ImGui::GetItemRectMax(); + const unsigned int texture = application_icon_texture(application.id); + if (texture) { + const float icon_size = ui(18.0f); + const ImVec2 icon_minimum{row_minimum.x + ui(5.0f), + row_minimum.y + (row_maximum.y - row_minimum.y - icon_size) * 0.5f}; + ImGui::GetWindowDrawList()->AddImage( + ImTextureRef(static_cast(texture)), icon_minimum, + {icon_minimum.x + icon_size, icon_minimum.y + icon_size}); + } + const float text_x = row_minimum.x + ui(31.0f); + const float text_y = row_minimum.y + + (row_maximum.y - row_minimum.y - ImGui::GetFontSize()) * 0.5f; + ImGui::GetWindowDrawList()->AddText({text_x, text_y}, + IM_COL32(226, 230, 235, 255), application.name.c_str()); + if (clicked) { open_repository_in(application.id); ImGui::CloseCurrentPopup(); } - ImGui::EndDisabled(); - if (!application.available && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("Not installed"); ImGui::PopID(); } ImGui::EndPopup(); @@ -1402,6 +1808,38 @@ void draw_licenses_popup() { } void draw_git_action_popups() { + static bool checkout_new_branch = true; + if (g_branch_create_popup) { + g_git_name.fill('\0'); + checkout_new_branch = true; + g_branch_create_popup = false; + ImGui::OpenPopup("Create local branch"); + } + if (ImGui::BeginPopupModal("Create local branch", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted("Branch name"); + ImGui::SetNextItemWidth(ui(380.0f)); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + const bool submit_with_enter = ImGui::InputText("##local_branch_name", + g_git_name.data(), g_git_name.size(), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::Checkbox("Check out after creating", &checkout_new_branch); + ImGui::TextDisabled("Start point: %.12s", g_git_target.empty() ? "HEAD" : g_git_target.c_str()); + const bool can_submit = g_git_name[0] != '\0'; + ImGui::BeginDisabled(!can_submit); + const bool submit = ImGui::Button("Create branch", {ui(125.0f), 0}) || submit_with_enter; + if (submit && can_submit && g_git_manager->createBranch(repo(), g_git_name.data(), + g_git_target, checkout_new_branch, g_notice)) { + g_git_target.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel", {ui(90.0f), 0})) { + g_git_target.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + if (g_tag_create_popup) { g_git_name.fill('\0'); g_tag_create_popup = false; @@ -1485,14 +1923,22 @@ void draw_git_action_popups() { if (ImGui::BeginPopupModal("Add submodule", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::TextUnformatted("Repository URL"); ImGui::SetNextItemWidth(ui(460.0f)); - ImGui::InputText("##submodule_url", g_git_value.data(), g_git_value.size()); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + bool submit_with_enter = ImGui::InputText("##submodule_url", + g_git_value.data(), g_git_value.size(), ImGuiInputTextFlags_EnterReturnsTrue); ImGui::TextUnformatted("Local path (optional)"); ImGui::SetNextItemWidth(ui(460.0f)); - ImGui::InputText("##submodule_path", g_git_path.data(), g_git_path.size()); - ImGui::BeginDisabled(g_git_value[0] == '\0'); - if (ImGui::Button("Add submodule", {ui(125.0f), 0}) && - g_git_manager->addSubmodule(repo(), g_git_value.data(), g_git_path.data(), g_notice)) + submit_with_enter |= ImGui::InputText("##submodule_path", + g_git_path.data(), g_git_path.size(), ImGuiInputTextFlags_EnterReturnsTrue); + const bool can_submit = g_git_value[0] != '\0'; + ImGui::BeginDisabled(!can_submit); + const bool submit = ImGui::Button("Add submodule", {ui(125.0f), 0}) || submit_with_enter; + if (submit && can_submit && + g_git_manager->addSubmodule(repo(), g_git_value.data(), g_git_path.data(), g_notice)) { + g_git_value.fill('\0'); + g_git_path.fill('\0'); ImGui::CloseCurrentPopup(); + } ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button("Cancel", {ui(90.0f), 0})) ImGui::CloseCurrentPopup(); @@ -1582,7 +2028,7 @@ void draw_new_tab() { std::string error; if (!izo::RevealInFileManager(recent[i], &error)) g_notice = error; } - if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) ImGui::SetClipboardText(recent[i].c_str()); + if (ImGui::MenuItem(ICON_FA_COPY " Copy path")) copy_to_clipboard(recent[i], "path"); ImGui::EndPopup(); } ImGui::SameLine(ui(260)); @@ -1626,10 +2072,11 @@ void draw_app() { size_t tab_move_to = g_tabs.size(); bool add_tab = false; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ImGui::GetStyle().FramePadding.x, ui(5.0f)}); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ImGui::GetStyle().ItemSpacing.x, 0.0f}); const ImVec2 tab_strip_minimum = ImGui::GetCursorScreenPos(); ImGui::GetWindowDrawList()->AddRectFilled(tab_strip_minimum, {tab_strip_minimum.x + ImGui::GetContentRegionAvail().x, tab_strip_minimum.y + ImGui::GetFrameHeight()}, - IM_COL32(42, 45, 52, 255)); + IM_COL32(31, 31, 31, 255)); if (ImGui::BeginTabBar("repositories", ImGuiTabBarFlags_AutoSelectNewTabs)) { for (size_t i = 0; i < g_tabs.size(); ++i) { ImGui::PushID(g_tabs[i].get()); @@ -1663,7 +2110,7 @@ void draw_app() { if (!izo::RevealInFileManager(g_tabs[i]->path, &error)) g_notice = error; } if (!g_tabs[i]->path.empty() && ImGui::MenuItem(ICON_FA_COPY " Copy repository path")) - ImGui::SetClipboardText(g_tabs[i]->path.c_str()); + copy_to_clipboard(g_tabs[i]->path, "repository path"); ImGui::Separator(); if (ImGui::MenuItem(ICON_FA_XMARK " Close tab")) tab_to_close = i; ImGui::EndPopup(); @@ -1672,23 +2119,25 @@ void draw_app() { ImGui::PopID(); } ImGui::SameLine(0, ui(6.0f)); - ImGui::InvisibleButton("##new_tab", {ui(22.0f), ui(22.0f)}); + const float add_button_size = ImGui::GetFrameHeight(); + ImGui::InvisibleButton("##new_tab", {add_button_size, add_button_size}); add_tab = ImGui::IsItemClicked(); const ImVec2 add_minimum = ImGui::GetItemRectMin(); const ImVec2 add_maximum = ImGui::GetItemRectMax(); const bool add_hovered = ImGui::IsItemHovered(); ImDrawList* tab_draw = ImGui::GetWindowDrawList(); - if (add_hovered) tab_draw->AddRectFilled(add_minimum, add_maximum, IM_COL32(51, 55, 63, 255)); - const ImVec2 add_icon_size = ImGui::CalcTextSize(ICON_FA_CIRCLE_PLUS); + if (add_hovered) + tab_draw->AddRectFilled(add_minimum, add_maximum, IM_COL32(51, 55, 63, 255), ui(5.0f)); + const ImVec2 add_icon_size = ImGui::CalcTextSize(ICON_FA_PLUS); tab_draw->AddText({ add_minimum.x + (add_maximum.x - add_minimum.x - add_icon_size.x) * 0.5f, add_minimum.y + (add_maximum.y - add_minimum.y - add_icon_size.y) * 0.5f, - }, IM_COL32(214, 221, 231, 255), ICON_FA_CIRCLE_PLUS); + }, IM_COL32(214, 221, 231, 255), ICON_FA_PLUS); if (add_hovered) ImGui::SetTooltip("New tab"); ImGui::EndTabBar(); g_tab_to_select = nullptr; } - ImGui::PopStyleVar(); + ImGui::PopStyleVar(2); if (tab_move_from < g_tabs.size() && tab_move_to < g_tabs.size() && tab_move_from != tab_move_to) { RepositoryView* active = g_tabs[g_active_tab].get(); auto moved = std::move(g_tabs[tab_move_from]); @@ -1711,10 +2160,11 @@ void draw_app() { return; } + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ImGui::GetStyle().ItemSpacing.x, 0.0f}); ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(51 / 255.0f, 55 / 255.0f, 63 / 255.0f, 1.0f)); - ImGui::BeginChild("repo_toolbar", {-1, ui(42)}, ImGuiChildFlags_None, + ImGui::BeginChild("repo_toolbar", {-1, ui(50)}, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::SetCursorPos({ui(5), ui(1)}); + ImGui::SetCursorPos({ui(5), ui(2)}); const float repository_width = std::clamp( ImGui::CalcTextSize(repo().name.c_str()).x / g_ui_scale + 42.0f, 120.0f, 220.0f); if (toolbar_selector("repository", "repository", repo().name, repository_width)) @@ -1730,10 +2180,10 @@ void draw_app() { ImGui::EndPopup(); } ImGui::SameLine(0, ui(3)); - ImGui::SetCursorPosY(ui(19)); + ImGui::SetCursorPosY(ui(23)); ImGui::TextDisabled(ICON_FA_ANGLE_RIGHT); ImGui::SameLine(0, ui(3)); - ImGui::SetCursorPosY(ui(1)); + ImGui::SetCursorPosY(ui(2)); if (g_request_branch_selector) { ImGui::OpenPopup("branch_selector"); g_request_branch_selector = false; @@ -1767,7 +2217,7 @@ void draw_app() { const float action_spacing = ui(3.0f); const float action_group_width = ui(418.0f); const float centered_x = (ImGui::GetWindowWidth() - action_group_width) * 0.5f; - ImGui::SetCursorPos({std::max(selectors_right + ui(10.0f), centered_x), ui(1.0f)}); + ImGui::SetCursorPos({std::max(selectors_right + ui(10.0f), centered_x), ui(2.0f)}); if (toolbar_action("undo", "Undo", ICON_FA_ROTATE_LEFT, "Undo last Git action", true, false, 54)) g_git_manager->undoCommit(repo(), g_notice); @@ -1797,6 +2247,7 @@ void draw_app() { draw_open_in_button(); ImGui::EndChild(); ImGui::PopStyleColor(); + ImGui::PopStyleVar(); draw_sidebar(ui(g_sidebar_width)); ImGui::SameLine(0, 0); @@ -1810,7 +2261,7 @@ void draw_app() { ImGui::GetWindowDrawList()->AddLine( {(splitter_min.x + splitter_max.x) * 0.5f, splitter_min.y}, {(splitter_min.x + splitter_max.x) * 0.5f, splitter_max.y}, - ImGui::IsItemHovered() || ImGui::IsItemActive() ? IM_COL32(23, 181, 204, 255) : IM_COL32(55, 59, 67, 255), + ImGui::IsItemHovered() || ImGui::IsItemActive() ? IM_COL32(23, 181, 204, 255) : IM_COL32(0, 0, 0, 0), ui(1.0f)); ImGui::SameLine(0, 0); const float content_width = ImGui::GetContentRegionAvail().x; @@ -1839,7 +2290,7 @@ void draw_app() { {(details_splitter_min.x + details_splitter_max.x) * 0.5f, details_splitter_min.y}, {(details_splitter_min.x + details_splitter_max.x) * 0.5f, details_splitter_max.y}, details_splitter_active || details_splitter_hovered - ? IM_COL32(23, 181, 204, 255) : IM_COL32(55, 59, 67, 255), ui(1.0f)); + ? IM_COL32(23, 181, 204, 255) : IM_COL32(0, 0, 0, 0), ui(1.0f)); ImGui::SameLine(0, 0); draw_details(detail_width); @@ -1880,6 +2331,8 @@ int runGitree(int argc, char** argv) { g_window_manager = &window_manager; AvatarCache avatar_cache(user_data.directory()); g_avatar_cache = &avatar_cache; + ApplicationIconCache application_icon_cache; + g_application_icon_cache = &application_icon_cache; IMGUI_CHECKVERSION(); ImGui::CreateContext(); @@ -1908,7 +2361,9 @@ int runGitree(int argc, char** argv) { } avatar_cache.shutdown(); + application_icon_cache.shutdown(); g_avatar_cache = nullptr; + g_application_icon_cache = nullptr; g_application_manager = nullptr; ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); diff --git a/src/ui/graph_renderer.cpp b/src/ui/graph_renderer.cpp index 559f2cc..caa18d2 100644 --- a/src/ui/graph_renderer.cpp +++ b/src/ui/graph_renderer.cpp @@ -7,6 +7,41 @@ #include #include +namespace { + +void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent, + ImU32 color, float scale, int route_slot) { + const float thickness = 2.0f * scale; + const auto stroke = [&](ImU32 stroke_color, float stroke_thickness) { + draw->PathClear(); + draw->PathLineTo(child); + const float horizontal = parent.x - child.x; + const float vertical = parent.y - child.y; + if (std::abs(horizontal) < 0.5f * scale || vertical <= 0.0f) { + draw->PathLineTo(parent); + draw->PathStroke(stroke_color, stroke_thickness); + return; + } + + // First-parent lane changes turn on the parent commit row. Additional merge + // parents leave horizontally on the child row. Both routes terminate as + // square T-junctions instead of bending through the front or back of a node. + if (route_slot == 0) { + draw->PathLineTo({child.x, parent.y}); + draw->PathLineTo(parent); + } else { + draw->PathLineTo({parent.x, child.y}); + draw->PathLineTo(parent); + } + draw->PathStroke(stroke_color, stroke_thickness); + }; + + stroke(IM_COL32(17, 21, 27, 255), thickness + 2.4f * scale); + stroke(color, thickness); +} + +} // namespace + ImU32 GraphRenderer::laneColor(int lane, int alpha) { static constexpr ImVec4 colors[] = { {0.08f, 0.70f, 0.83f, 1.0f}, @@ -25,35 +60,48 @@ ImU32 GraphRenderer::laneColor(int lane, int alpha) { float GraphRenderer::requiredWidth(const std::vector& commits, float scale) { int maximum_lane = 0; for (const auto& commit : commits) maximum_lane = std::max(maximum_lane, commit.lane); - return std::clamp((42.0f + maximum_lane * 18.0f) * scale, 56.0f * scale, 220.0f * scale); + return std::max((46.0f + maximum_lane * 22.0f) * scale, 60.0f * scale); } void GraphRenderer::drawRow(int row, const CommitInfo& commit, const std::vector& commits, const std::vector& row_heights, - const std::vector>& parent_rows, AvatarCache* avatars) const { + const std::vector>& parent_rows, AvatarCache* avatars, + const ImVec2* ref_connector_start) const { ImDrawList* draw = ImGui::GetWindowDrawList(); const ImVec2 origin = ImGui::GetCursorScreenPos(); const float row_height = row_heights[static_cast(row)]; const float content_height = std::max(px(1.0f), row_height - ImGui::GetStyle().CellPadding.y * 2.0f); - const float lane_spacing = px(18.0f); - const float x = origin.x + px(15.0f) + lane_spacing * commit.lane; + const float lane_spacing = px(22.0f); + const float x = origin.x + px(17.0f) + lane_spacing * commit.lane; const float y = origin.y + content_height * 0.5f; const float cell_right = origin.x + ImGui::GetContentRegionAvail().x; const float row_clip_padding = ImGui::GetStyle().CellPadding.y + px(1.0f); - // GitKraken-style lane ribbon: a quiet tint carries the branch color through - // the rest of the graph column while the far edge remains crisply identifiable. + // Start the lane ribbon at the profile circle's centerline. The opaque node backing + // masks its left edge, while the vertical inset keeps neighboring row tints separate. + const float ribbon_left = std::min(x, cell_right); + const float ribbon_top = origin.y + px(1.0f); + const float ribbon_bottom = origin.y + content_height - px(1.0f); draw->AddRectFilled( - {x, origin.y - row_clip_padding}, - {cell_right, origin.y + content_height + row_clip_padding}, - laneColor(commit.lane, 38)); - draw->AddLine( - {cell_right - px(1.0f), origin.y - row_clip_padding}, - {cell_right - px(1.0f), origin.y + content_height + row_clip_padding}, - laneColor(commit.lane, 220), px(2.0f)); - if (!commit.refs.empty()) - draw->AddLine({origin.x - ImGui::GetStyle().CellPadding.x, y}, {x, y}, - laneColor(commit.lane, 150), px(1.0f)); + {ribbon_left, ribbon_top}, + {cell_right, ribbon_bottom}, + laneColor(commit.graph_color, 38)); + if (ref_connector_start) { + const ImVec2 end{x, y}; + const float turn_x = end.x - px(8.0f); + const auto stroke_connector = [&](ImU32 color, float thickness) { + draw->PathClear(); + draw->PathLineTo(*ref_connector_start); + draw->PathLineTo({turn_x, ref_connector_start->y}); + draw->PathLineTo({turn_x, end.y}); + draw->PathLineTo(end); + draw->PathStroke(color, thickness); + }; + draw->PushClipRectFullScreen(); + stroke_connector(IM_COL32(17, 21, 27, 255), px(3.8f)); + stroke_connector(laneColor(commit.graph_color, 175), px(1.6f)); + draw->PopClipRect(); + } std::vector row_offsets(row_heights.size() + 1, 0.0f); for (size_t index = 0; index < row_heights.size(); ++index) @@ -72,49 +120,48 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit, for (int child_row = 0; child_row < static_cast(commits.size()); ++child_row) { if (row_heights[static_cast(child_row)] <= 0.0f) continue; const CommitInfo& child = commits[static_cast(child_row)]; - for (const int parent_row : parent_rows[static_cast(child_row)]) { + const auto& child_parents = parent_rows[static_cast(child_row)]; + for (size_t parent_index = 0; parent_index < child_parents.size(); ++parent_index) { + const int parent_row = child_parents[parent_index]; if (parent_row <= child_row || row < child_row || row > parent_row || row_heights[static_cast(parent_row)] <= 0.0f) continue; const CommitInfo& parent = commits[static_cast(parent_row)]; - const float child_x = origin.x + px(15.0f) + lane_spacing * child.lane; - const float parent_x = origin.x + px(15.0f) + lane_spacing * parent.lane; + const float child_x = origin.x + px(17.0f) + lane_spacing * child.lane; + const float parent_x = origin.x + px(17.0f) + lane_spacing * parent.lane; const float child_y = center_y(child_row); const float parent_y = center_y(parent_row); - // An edge belongs to the branch it leaves, including the curved transition - // into a parent lane, so its color stays consistent with the child node/ref chip. - const ImU32 color = laneColor(child.lane, 225); - if (child.lane == parent.lane) { - draw->AddLine({child_x, child_y}, {parent_x, parent_y}, color, px(1.8f)); - continue; - } - - const float bend_height = std::min(parent_y - child_y, px(24.0f)); - const float curve_end_y = child_y + bend_height; - draw->AddBezierCubic( - {child_x, child_y}, - {child_x, child_y + bend_height * 0.45f}, - {parent_x, child_y + bend_height * 0.55f}, - {parent_x, curve_end_y}, - color, px(1.8f)); - if (curve_end_y < parent_y) - draw->AddLine({parent_x, curve_end_y}, {parent_x, parent_y}, color, px(1.8f)); + // An edge belongs to the branch it leaves, including its square lane transition, + // so its color stays consistent with the child node/ref chip. + const int edge_color = parent_index == 0 ? child.graph_color : parent.graph_color; + drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y}, + laneColor(edge_color, 235), scale_, static_cast(parent_index)); } } draw->PopClipRect(); - const ImU32 lane_color = laneColor(commit.lane); + const ImU32 lane_color = laneColor(commit.graph_color); + const auto show_identity_tooltip = [&](float hit_radius) { + if (!ImGui::IsWindowHovered()) return; + const ImVec2 mouse = ImGui::GetIO().MousePos; + const float delta_x = mouse.x - x; + const float delta_y = mouse.y - y; + if (delta_x * delta_x + delta_y * delta_y > hit_radius * hit_radius) return; + if (commit.email.empty()) ImGui::SetTooltip("%s", commit.author.c_str()); + else ImGui::SetTooltip("%s <%s>", commit.author.c_str(), commit.email.c_str()); + }; if (commit.parents > 1) { - draw->AddCircleFilled({x, y}, px(4.5f), lane_color); - draw->AddCircle({x, y}, px(6.0f), laneColor(commit.lane, 110), 0, px(1.0f)); + draw->AddCircleFilled({x, y}, px(6.0f), lane_color); + draw->AddCircle({x, y}, px(7.5f), laneColor(commit.graph_color, 130), 0, px(1.2f)); + show_identity_tooltip(px(9.0f)); ImGui::Dummy({0.0f, content_height}); return; } - const float radius = px(8.4f); + const float radius = px(9.6f); // The opaque backing masks every lane segment before the avatar is painted. - draw->AddCircleFilled({x, y}, radius + px(2.2f), IM_COL32(19, 24, 31, 255)); - draw->AddCircle({x, y}, radius + px(1.0f), lane_color, 0, px(2.0f)); + draw->AddCircleFilled({x, y}, radius + px(1.8f), IM_COL32(19, 24, 31, 255)); + draw->AddCircle({x, y}, radius + px(0.8f), lane_color, 0, px(2.0f)); draw->AddCircleFilled({x, y}, radius - px(1.0f), IM_COL32(232, 238, 242, 255)); const unsigned int texture = avatars ? avatars->textureFor(commit.email) : 0; @@ -127,5 +174,6 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit, const ImVec2 icon_size = ImGui::CalcTextSize(ICON_FA_USER); draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_FA_USER); } + show_identity_tooltip(radius + px(2.0f)); ImGui::Dummy({0.0f, content_height}); } diff --git a/src/ui/graph_renderer.h b/src/ui/graph_renderer.h index e0935aa..7cc41b2 100644 --- a/src/ui/graph_renderer.h +++ b/src/ui/graph_renderer.h @@ -15,7 +15,7 @@ public: void drawRow(int row, const CommitInfo& commit, const std::vector& commits, const std::vector& row_heights, const std::vector>& parent_rows, - AvatarCache* avatars) const; + AvatarCache* avatars, const ImVec2* ref_connector_start = nullptr) const; private: float px(float value) const { return value * scale_; }