diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index 8388b2a..aa4d9de 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -14,6 +14,7 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Install dependencies run: | @@ -38,21 +39,43 @@ 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: Generate EXE name + run: | + repo_name="$(basename "${GITEA_REPOSITORY}")" + short_sha="$(printf '%s' "${GITEA_SHA}" | cut -c1-7)" + + exe_name="${repo_name}-windows-x64-${GITEA_RUN_NUMBER}-${short_sha}.exe" + + echo "EXE_NAME=${exe_name}" >> "$GITHUB_ENV" + echo "ARTIFACT_NAME=${repo_name}-windows-x64-${GITEA_RUN_NUMBER}-${short_sha}" >> "$GITHUB_ENV" + - name: Prepare artifact run: | mkdir -p dist - cp build-win/bin/gitree.exe dist/Gitree-windows-x64.exe + cp build-win/bin/gitree.exe "dist/${EXE_NAME}" - name: Upload Windows build uses: actions/upload-artifact@v3 with: - name: Gitree-windows-x64 - path: dist/Gitree-windows-x64.exe + name: ${{ env.ARTIFACT_NAME }} + path: dist/${{ env.EXE_NAME }} if-no-files-found: error - name: Create prod release @@ -69,15 +92,101 @@ jobs: exit 1 fi + git fetch --tags --force + short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)" tag="prod-${GITEA_RUN_NUMBER}-${short_sha}" api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}" + repo_url="${GITEA_SERVER_URL%/}/${GITEA_REPOSITORY}" + + previous_tag="$( + curl --fail-with-body --silent --show-error \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${api}/releases?limit=50" | + jq -r '[.[] | select(.tag_name | startswith("prod-"))][0].tag_name // empty' + )" + + if [ -n "$previous_tag" ]; then + range="${previous_tag}..${GITEA_SHA}" + echo "Generating release notes from ${previous_tag} to ${GITEA_SHA}" + else + range="${GITEA_SHA}" + echo "No previous prod release found. Generating release notes from current commit." + fi + + { + echo "Automated Windows release from the prod branch." + echo + + if [ -n "$previous_tag" ]; then + echo "## Changes since ${previous_tag}" + else + echo "## Changes" + fi + + echo + + commit_count="$(git rev-list --count "$range")" + + if [ "$commit_count" -eq 0 ]; then + echo "- No commits found since the previous release." + else + git log "$range" \ + --reverse \ + --pretty=format:'%H%x1f%s' | + while IFS="$(printf '\037')" read -r hash subject; do + short="$(printf '%s' "$hash" | cut -c1-7)" + echo "- ([${short}](${repo_url}/commit/${hash})) ${subject}" + done + fi + + echo + echo "## Authors" + echo + echo "Sorted by total lines added or removed." + echo + + git log "$range" --numstat --format='author:%an <%ae>' | + awk ' + /^author:/ { + author = substr($0, 8) + next + } + + NF >= 3 { + added = ($1 == "-" ? 0 : $1) + removed = ($2 == "-" ? 0 : $2) + + adds[author] += added + dels[author] += removed + churn[author] += added + removed + } + + END { + for (a in churn) { + printf "%d\t%d\t%d\t%s\n", churn[a], adds[a], dels[a], a + } + } + ' | + sort -nr | + while IFS="$(printf '\t')" read -r total added removed author; do + echo "- ${author} — ${total} lines changed (+${added} / -${removed})" + done + } > release-notes.md payload="$(jq -n \ --arg tag "$tag" \ --arg sha "$GITEA_SHA" \ - --arg name "Gitree ${tag}" \ - '{tag_name: $tag, target_commitish: $sha, name: $name, body: "Automated Windows release from the prod branch.", draft: false, prerelease: false}')" + --arg name "${ARTIFACT_NAME}" \ + --rawfile body release-notes.md \ + '{ + tag_name: $tag, + target_commitish: $sha, + name: $name, + body: $body, + draft: false, + prerelease: false + }')" release="$(curl --fail-with-body --silent --show-error \ -X POST \ @@ -85,10 +194,11 @@ jobs: -H "Content-Type: application/json" \ --data "$payload" \ "${api}/releases")" + release_id="$(printf '%s' "$release" | jq -er '.id')" curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ - -F "attachment=@dist/Gitree-windows-x64.exe" \ - "${api}/releases/${release_id}/assets?name=Gitree-windows-x64.exe" + -F "attachment=@dist/${EXE_NAME}" \ + "${api}/releases/${release_id}/assets?name=${EXE_NAME}" \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index e6e1984..62ab751 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) @@ -79,6 +81,7 @@ target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikv::ikv Open target_compile_definitions(gitree PRIVATE GITREE_VERSION="${PROJECT_VERSION}" GITREE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/vendor/fonts" + GITREE_IMAGE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets" $<$:NOMINMAX;WIN32_LEAN_AND_MEAN> ) 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..5a8c31d 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,211 @@ #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 +{ + std::string trim(std::string value) + { + const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char character) + { return std::isspace(character) != 0; }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char character) + { return std::isspace(character) != 0; }).base(); + if (first >= last) + return {}; + return std::string(first, last); + } + + bool startsWith(std::string_view text, std::string_view prefix) + { + return text.size() >= prefix.size() && text.substr(0, prefix.size()) == prefix; + } + + bool parseCheckoutReflog(std::string_view subject, std::string &from, std::string &to) + { + constexpr std::string_view prefix = "checkout: moving from "; + if (!startsWith(subject, prefix)) + return false; + const size_t separator = subject.find(" to ", prefix.size()); + if (separator == std::string_view::npos) + return false; + from = trim(std::string(subject.substr(prefix.size(), separator - prefix.size()))); + to = trim(std::string(subject.substr(separator + 4))); + return !from.empty() && !to.empty(); + } + + std::string commitSummaryFromReflog(std::string_view subject) + { + const size_t colon = subject.find(':'); + return colon == std::string_view::npos ? std::string{} : trim(std::string(subject.substr(colon + 1))); + } + + ToolbarHistoryAction unavailableAction(const char *tooltip) + { + ToolbarHistoryAction action; + action.tooltip = tooltip; + return action; + } + + ToolbarHistoryAction makeCheckoutAction(const std::string &from, const std::string &to, const char *verb) + { + ToolbarHistoryAction action; + action.kind = ToolbarHistoryActionKind::checkout; + action.available = true; + action.source = from; + action.target = to; + action.tooltip = std::string(verb) + " Checkout from \"" + from + "\" to \"" + to + "\""; + return action; + } + + ToolbarHistoryAction makeCommitAction(std::string summary, const char *verb) + { + ToolbarHistoryAction action; + action.kind = ToolbarHistoryActionKind::commit; + action.available = true; + action.summary = std::move(summary); + action.tooltip = action.summary.empty() ? std::string(verb) + " Last Commit" + : std::string(verb) + " Commit \"" + action.summary + "\""; + return action; + } + + 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 +231,82 @@ 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) + { + if (type == GIT_BRANCH_REMOTE && std::string_view(name).ends_with("/HEAD")) + { + git_reference_free(reference); + continue; + } + 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 +315,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 +345,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 +363,215 @@ 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{}; + }; + std::vector lanes; + 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}); + 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 = commit.lane; + 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]}); } } } -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); +void GitManager::loadToolbarHistoryActions(RepositoryView &repository) +{ + repository.undo_action = unavailableAction("No recent action found to undo"); + repository.redo_action = unavailableAction("No recent undo found to redo"); + + std::string output; + std::string error; + if (!captureGit(repository, {"reflog", "--format=%gs", "-n", "2", "HEAD"}, output, error)) + return; + + std::vector entries; + std::istringstream stream(output); + std::string line; + while (std::getline(stream, line)) + { + if (!line.empty() && line.back() == '\r') + line.pop_back(); + if (!line.empty()) + entries.push_back(std::move(line)); + } + if (entries.empty()) + return; + + std::string from; + std::string to; + if (parseCheckoutReflog(entries[0], from, to)) + { + repository.undo_action = makeCheckoutAction(from, to, "Undo"); + return; + } + + if (startsWith(entries[0], "commit")) + { + repository.undo_action = makeCommitAction(commitSummaryFromReflog(entries[0]), "Undo"); + return; + } + + if (startsWith(entries[0], "reset: moving to HEAD~1") && entries.size() > 1 && + startsWith(entries[1], "commit")) + { + repository.redo_action = makeCommitAction(commitSummaryFromReflog(entries[1]), "Redo"); + } +} + +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(); + loadToolbarHistoryActions(repository); - 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 +580,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 +605,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 +672,70 @@ 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, + const std::string &initial_branch, std::string &error) +{ + git_repository *created = nullptr; + git_repository_init_options options{}; + git_repository_init_options_init(&options, GIT_REPOSITORY_INIT_OPTIONS_VERSION); + options.flags = GIT_REPOSITORY_INIT_MKPATH; + options.initial_head = initial_branch.empty() ? "main" : initial_branch.c_str(); + if (git_repository_init_ext(&created, path.c_str(), &options) != 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::cloneRepository(RepositoryView &repository, const std::string &url, + const std::string &path, bool shallow, std::string &error) +{ + git_clone_options options{}; + git_clone_options_init(&options, GIT_CLONE_OPTIONS_VERSION); + if (shallow) + options.fetch_opts.depth = 1; + git_repository *cloned = nullptr; + if (git_clone(&cloned, url.c_str(), path.c_str(), &options) != 0) + { + error = lastError("Unable to clone repository"); + return false; + } + repository.close(); + repository.repo = cloned; + return loadRepositoryData(repository, 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 +744,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 +785,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::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error) +{ + if (!prepareCredentials(repository, error)) + return false; + + 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; + } + + std::string remote_name; + std::string remote_branch_name; + git_reference *upstream = nullptr; + if (git_branch_upstream(&upstream, reference) == 0) + { + const char *upstream_name = nullptr; + if (git_branch_name(&upstream_name, upstream) == 0 && upstream_name) + { + remote_branch_name = upstream_name; + const size_t slash = remote_branch_name.find('/'); + remote_name = slash == std::string::npos ? remote_branch_name : remote_branch_name.substr(0, slash); + if (slash != std::string::npos) + remote_branch_name.erase(0, slash + 1); + } + git_reference_free(upstream); + } + git_reference_free(reference); + + if (!remote_name.empty() && !remote_branch_name.empty()) + return runGit(repository, {"push", remote_name, branch + ":" + remote_branch_name}, + "Push complete", error); + + if (repository.remotes.empty()) + { + error = "No remote is configured for this repository"; + return false; + } + + const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") != + repository.remotes.end() + ? "origin" + : repository.remotes.front(); + return runGit(repository, {"push", "--set-upstream", remote, branch + ":" + branch}, + "Push complete; upstream configured", error); +} + +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) { - return runGit(repository, {"reset", "--soft", "HEAD~1"}, "Last commit moved back to staging", error); +bool GitManager::undoCommit(RepositoryView &repository, std::string &error) +{ + if (!repository.undo_action.available) + { + error = repository.undo_action.tooltip.empty() ? "No recent action found to undo" : repository.undo_action.tooltip; + return false; + } + + std::vector arguments; + std::string success_message; + ToolbarHistoryAction redo_action = unavailableAction("No recent undo found to redo"); + switch (repository.undo_action.kind) + { + case ToolbarHistoryActionKind::checkout: + arguments = {"checkout", repository.undo_action.source}; + success_message = "Checked out " + repository.undo_action.source; + redo_action = makeCheckoutAction(repository.undo_action.source, repository.undo_action.target, "Redo"); + break; + case ToolbarHistoryActionKind::commit: + arguments = {"reset", "--soft", "HEAD~1"}; + success_message = "Last commit moved back to staging"; + redo_action = makeCommitAction(repository.undo_action.summary, "Redo"); + break; + default: + error = "No supported action is available to undo"; + return false; + } + + std::string command_output; + if (!captureGit(repository, arguments, command_output, error)) + return false; + if (!loadRepositoryData(repository, error)) + return false; + repository.redo_action = std::move(redo_action); + error = success_message; + return true; } -bool GitManager::redoCommit(RepositoryView& repository, std::string& error) { - return runGit(repository, {"reset", "--soft", "HEAD@{1}"}, "Commit restored from reflog", error); +bool GitManager::redoCommit(RepositoryView &repository, std::string &error) +{ + if (!repository.redo_action.available) + { + error = repository.redo_action.tooltip.empty() ? "No recent undo found to redo" : repository.redo_action.tooltip; + return false; + } + + std::vector arguments; + std::string success_message; + switch (repository.redo_action.kind) + { + case ToolbarHistoryActionKind::checkout: + arguments = {"checkout", repository.redo_action.target}; + success_message = "Checked out " + repository.redo_action.target; + break; + case ToolbarHistoryActionKind::commit: + arguments = {"reset", "--soft", "HEAD@{1}"}; + success_message = "Commit restored from reflog"; + break; + default: + error = "No supported action is available to redo"; + return false; + } + + std::string command_output; + if (!captureGit(repository, arguments, command_output, error)) + return false; + if (!loadRepositoryData(repository, error)) + return false; + error = success_message; + return true; } -bool GitManager::stageAll(RepositoryView& repository, std::string& error) { - return runGit(repository, {"add", "--all"}, "All changes staged", error); +bool GitManager::stageAll(RepositoryView &repository, std::string &error) +{ + if (!runGit(repository, {"add", "--all"}, "All changes staged", error, false)) + return false; + loadWorkingTree(repository); + return true; } -bool GitManager::stageFile(RepositoryView& repository, const std::string& path, std::string& error) { +bool GitManager::unstageAll(RepositoryView &repository, std::string &error) +{ + if (git_repository_head_unborn(repository.repo) == 1) + { + if (!runGit(repository, {"rm", "--cached", "-r", "--ignore-unmatch", "--", "."}, + "All changes unstaged", error, false)) + return false; + loadWorkingTree(repository); + return true; + } + if (runGit(repository, {"restore", "--staged", "--", "."}, "All changes unstaged", error, false)) + { + loadWorkingTree(repository); + return true; + } + if (!runGit(repository, {"reset", "HEAD", "--", "."}, "All changes unstaged", error, false)) + return false; + loadWorkingTree(repository); + return true; +} + +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 (git_repository_head_unborn(repository.repo) == 1) + return runGit(repository, {"rm", "--cached", "--ignore-unmatch", "--", path}, + "File unstaged", 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 +1263,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 +1272,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 +1323,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..8bb4fcf 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -4,57 +4,66 @@ #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, + const std::string &initial_branch, std::string &error); + bool cloneRepository(RepositoryView &repository, const std::string &url, + const std::string &path, bool shallow, 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 pushBranch(RepositoryView &repository, const std::string &branch, std::string &error); + bool stash(RepositoryView &repository, std::string &error); + bool popStash(RepositoryView &repository, std::string &error); + bool undoCommit(RepositoryView &repository, std::string &error); + bool redoCommit(RepositoryView &repository, std::string &error); + bool stageAll(RepositoryView &repository, std::string &error); + bool unstageAll(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 loadToolbarHistoryActions(RepositoryView &repository); + 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..94e1d97 100644 --- a/src/managers/user_data.cpp +++ b/src/managers/user_data.cpp @@ -1,72 +1,111 @@ #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() { - 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)) +std::filesystem::path UserData::dataPath() const +{ + return directory_ / "user_data.ikv2b"; +} + +void UserData::removeLegacyFiles() const +{ + std::error_code error; + for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", "user_data.ikv"}) + std::filesystem::remove(directory_ / name, error); +} + +void UserData::load() +{ + const std::filesystem::path binary_path = dataPath(); + if (ikv_node_t *root = ikvb_parse_file(binary_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 *value = object_value(settings, "zoom_percent", IKV_INT)) + zoom_percent_ = static_cast(ikv_as_int(value)); + if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) + { const uint32_t count = std::min( ikv_array_size(heights), static_cast(sidebar_section_heights_.size())); for (uint32_t index = 0; index < count; ++index) sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); } } - if (const ikv_node_t* 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); + if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) + { + const uint32_t count = std::min(ikv_array_size(history), 100); + for (uint32_t index = 0; index < count; ++index) + { + const char *path = ikv_as_string(ikv_array_get(history, index)); + if (path && *path) + 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 *recent = object_value(root, "recent_repositories", IKV_ARRAY)) + { + const uint32_t count = std::min(ikv_array_size(recent), 100); + for (uint32_t index = 0; index < count; ++index) + { + const char *path = ikv_as_string(ikv_array_get(recent, index)); + if (path && *path) + recent_repositories_.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)) 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,81 +114,212 @@ 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); + zoom_percent_ = std::clamp(zoom_percent_, 80, 200); + 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; + } + + const std::filesystem::path text_data_path = directory_ / "user_data.ikv"; + if (ikv_node_t *root = ikv_parse_file(text_data_path.string().c_str())) + { + if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT)) + { + if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT)) + sidebar_width_ = static_cast(ikv_as_float(value)); + if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT)) + details_width_ = static_cast(ikv_as_float(value)); + if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT)) + pull_mode_ = static_cast(ikv_as_int(value)); + if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT)) + zoom_percent_ = static_cast(ikv_as_int(value)); + if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) + { + const uint32_t count = std::min( + ikv_array_size(heights), static_cast(sidebar_section_heights_.size())); + for (uint32_t index = 0; index < count; ++index) + sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); + } + } + if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) + { + const uint32_t count = std::min(ikv_array_size(history), 100); + for (uint32_t index = 0; index < count; ++index) + { + const char *path = ikv_as_string(ikv_array_get(history, index)); + if (path && *path) + recently_closed_.emplace_back(path); + } + } + if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY)) + { + const uint32_t count = std::min(ikv_array_size(recent), 100); + for (uint32_t index = 0; index < count; ++index) + { + const char *path = ikv_as_string(ikv_array_get(recent, index)); + if (path && *path) + recent_repositories_.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)) + active_repository_ = static_cast(std::max(0, ikv_as_int(active))); + if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY)) + { + for (uint32_t index = 0; index < ikv_array_size(tabs); ++index) + { + const char *path = ikv_as_string(ikv_array_get(tabs, index)); + open_repositories_.emplace_back(path ? path : ""); + } + } + } + ikv_free(root); + sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f); + details_width_ = std::clamp(details_width_, 280.0f, 650.0f); + pull_mode_ = std::clamp(pull_mode_, 0, 3); + zoom_percent_ = std::clamp(zoom_percent_, 80, 200); + for (float &height : sidebar_section_heights_) + height = std::clamp(height, 42.0f, 500.0f); + if (open_repositories_.empty()) + active_repository_ = 0; + else + active_repository_ = std::min(active_repository_, open_repositories_.size() - 1); + save(); 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 == "zoom_percent") + settings >> zoom_percent_; + 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); + zoom_percent_ = std::clamp(zoom_percent_, 80, 200); + for (float &height : sidebar_section_heights_) + height = std::clamp(height, 42.0f, 500.0f); std::ifstream history(directory_ / "history.txt"); std::string path; - 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() == 100) + break; } + recent_repositories_ = recently_closed_; 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); + save(); } -void UserData::addRecentlyClosed(const std::string& path) { - if (path.empty()) return; +void UserData::addRecentRepository(const std::string &path) +{ + if (path.empty()) + return; + std::erase(recent_repositories_, path); + recent_repositories_.insert(recent_repositories_.begin(), path); + if (recent_repositories_.size() > 100) + recent_repositories_.resize(100); + save(); +} + +void UserData::addRecentlyClosed(const std::string &path) +{ + if (path.empty()) + return; + std::erase(recent_repositories_, path); + recent_repositories_.insert(recent_repositories_.begin(), path); + if (recent_repositories_.size() > 100) + recent_repositories_.resize(100); 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() > 100) + recently_closed_.resize(100); save(); } -void UserData::setRepositorySession(std::vector paths, size_t active_repository) { +std::string UserData::takeRecentlyClosed() +{ + if (recently_closed_.empty()) + return {}; + std::string path = std::move(recently_closed_.front()); + recently_closed_.erase(recently_closed_.begin()); + save(); + return path; +} + +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_object_set_int(settings, "zoom_percent", zoom_percent_); + ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT); + for (const float height : sidebar_section_heights_) + ikv_array_add_float(heights, height); - ikv_node_t* 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 *recent = ikv_object_add_array(root, "recent_repositories", IKV_STRING); + for (const auto &path : recent_repositories_) + ikv_array_add_string(recent, path.c_str()); + + 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); + ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2); ikv_free(root); + removeLegacyFiles(); } diff --git a/src/managers/user_data.h b/src/managers/user_data.h index 944f18c..8826b8c 100644 --- a/src/managers/user_data.h +++ b/src/managers/user_data.h @@ -5,34 +5,49 @@ #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::vector &recentRepositories() const { return recent_repositories_; } + [[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_; } [[nodiscard]] float sidebarSectionHeight(size_t index) const { return sidebar_section_heights_.at(index); } [[nodiscard]] int pullMode() const { return pull_mode_; } + [[nodiscard]] int zoomPercent() const { return zoom_percent_; } 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 setZoomPercent(int percent) + { + zoom_percent_ = percent; + save(); + } + void addRecentRepository(const std::string &path); + void addRecentlyClosed(const std::string &path); + std::string takeRecentlyClosed(); void setRepositorySession(std::vector paths, size_t active_repository); void save() const; private: void load(); + [[nodiscard]] std::filesystem::path dataPath() const; + void removeLegacyFiles() const; std::filesystem::path directory_; - std::string imgui_ini_path_; + std::vector recent_repositories_; std::vector recently_closed_; std::vector open_repositories_; size_t active_repository_ = 0; @@ -40,4 +55,5 @@ private: float details_width_ = 368.0f; std::array sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f}; int pull_mode_ = 1; + int zoom_percent_ = 100; }; diff --git a/src/managers/window_manager.cpp b/src/managers/window_manager.cpp index 2bc96a5..73f24d4 100644 --- a/src/managers/window_manager.cpp +++ b/src/managers/window_manager.cpp @@ -2,36 +2,155 @@ #include #include +#include #include +#include +#include #ifdef _WIN32 #define GLFW_EXPOSE_NATIVE_WIN32 #include #include #include +#include #endif -WindowManager::~WindowManager() { +#ifdef _WIN32 +namespace +{ + HICON loadPngIcon(const std::filesystem::path &path, UINT size, bool rounded) + { + const HRESULT com_result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + const bool uninitialize_com = SUCCEEDED(com_result); + + IWICImagingFactory *factory = nullptr; + IWICBitmapDecoder *decoder = nullptr; + IWICBitmapFrameDecode *frame = nullptr; + IWICBitmapScaler *scaler = nullptr; + IWICFormatConverter *converter = nullptr; + HICON icon = nullptr; + + if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&factory))) || + FAILED(factory->CreateDecoderFromFilename(path.c_str(), nullptr, GENERIC_READ, + WICDecodeMetadataCacheOnLoad, &decoder)) || + FAILED(decoder->GetFrame(0, &frame)) || + FAILED(factory->CreateBitmapScaler(&scaler)) || + FAILED(scaler->Initialize(frame, size, size, WICBitmapInterpolationModeFant)) || + FAILED(factory->CreateFormatConverter(&converter)) || + FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) + { + goto cleanup; + } + + { + std::vector pixels(static_cast(size) * size * 4); + if (FAILED(converter->CopyPixels(nullptr, size * 4, + static_cast(pixels.size()), pixels.data()))) + { + goto cleanup; + } + + if (rounded) + { + const float radius = static_cast(size) * 0.22f; + const float maximum = static_cast(size) - radius - 0.5f; + const float minimum = radius - 0.5f; + for (UINT y = 0; y < size; ++y) + { + for (UINT x = 0; x < size; ++x) + { + const float nearest_x = std::clamp(static_cast(x), minimum, maximum); + const float nearest_y = std::clamp(static_cast(y), minimum, maximum); + const float dx = static_cast(x) - nearest_x; + const float dy = static_cast(y) - nearest_y; + const float coverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy), + 0.0f, 1.0f); + pixels[(static_cast(y) * size + x) * 4 + 3] = + static_cast(coverage * 255.0f); + } + } + } + + BITMAPV5HEADER header{}; + header.bV5Size = sizeof(header); + header.bV5Width = static_cast(size); + header.bV5Height = -static_cast(size); + header.bV5Planes = 1; + header.bV5BitCount = 32; + header.bV5Compression = BI_BITFIELDS; + header.bV5RedMask = 0x00ff0000; + header.bV5GreenMask = 0x0000ff00; + header.bV5BlueMask = 0x000000ff; + header.bV5AlphaMask = 0xff000000; + + void *bitmap_bits = nullptr; + const HDC screen = GetDC(nullptr); + const HBITMAP color = CreateDIBSection(screen, reinterpret_cast(&header), + DIB_RGB_COLORS, &bitmap_bits, nullptr, 0); + ReleaseDC(nullptr, screen); + const HBITMAP mask = CreateBitmap(size, size, 1, 1, nullptr); + if (color && mask && bitmap_bits) + { + std::memcpy(bitmap_bits, pixels.data(), pixels.size()); + ICONINFO info{}; + info.fIcon = TRUE; + info.hbmColor = color; + info.hbmMask = mask; + icon = CreateIconIndirect(&info); + } + if (color) + DeleteObject(color); + if (mask) + DeleteObject(mask); + } + + cleanup: + if (converter) + converter->Release(); + if (scaler) + scaler->Release(); + if (frame) + frame->Release(); + if (decoder) + decoder->Release(); + if (factory) + factory->Release(); + if (uninitialize_com) + CoUninitialize(); + return icon; + } +} // namespace +#endif + +WindowManager::~WindowManager() +{ destroy(); } -bool WindowManager::create(const char* title, int width, int height) { +bool WindowManager::create(const char *title, int width, int height) +{ #ifdef _WIN32 - using SetDpiAwarenessContext = BOOL(WINAPI*)(HANDLE); + using SetDpiAwarenessContext = BOOL(WINAPI *)(HANDLE); const HMODULE user32 = GetModuleHandleW(L"user32.dll"); const FARPROC dpi_address = GetProcAddress(user32, "SetProcessDpiAwarenessContext"); SetDpiAwarenessContext set_dpi_awareness = nullptr; static_assert(sizeof(set_dpi_awareness) == sizeof(dpi_address)); std::memcpy(&set_dpi_awareness, &dpi_address, sizeof(set_dpi_awareness)); - if (set_dpi_awareness) set_dpi_awareness(reinterpret_cast(-4)); // Per-monitor v2 + if (set_dpi_awareness) + set_dpi_awareness(reinterpret_cast(-4)); // Per-monitor v2 #endif - if (!glfwInit()) return false; + if (!glfwInit()) + return false; glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE); window_ = glfwCreateWindow(width, height, title, nullptr, nullptr); - if (!window_) { + if (!window_) + { glfwTerminate(); return false; } @@ -45,13 +164,17 @@ bool WindowManager::create(const char* title, int width, int height) { glfwGetWindowContentScale(window_, &x_scale, &y_scale); dpi_scale_ = std::max(x_scale, y_scale); applyNativeCaption(); + applyNativeIcons(); return true; } -void WindowManager::destroy() { - if (!window_) return; +void WindowManager::destroy() +{ + if (!window_) + return; glfwDestroyWindow(window_); window_ = nullptr; + destroyNativeIcons(); glfwTerminate(); } @@ -60,39 +183,126 @@ void WindowManager::swapBuffers() { glfwSwapBuffers(window_); } void WindowManager::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); } bool WindowManager::shouldClose() const { return !window_ || glfwWindowShouldClose(window_); } -bool WindowManager::consumeDpiChange() { +bool WindowManager::consumeDpiChange() +{ const bool changed = dpi_changed_; dpi_changed_ = false; return changed; } -void WindowManager::contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale) { - auto* manager = static_cast(glfwGetWindowUserPointer(window)); - if (manager) manager->updateDpi(std::max(x_scale, y_scale)); +void WindowManager::contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale) +{ + auto *manager = static_cast(glfwGetWindowUserPointer(window)); + if (manager) + manager->updateDpi(std::max(x_scale, y_scale)); } -void WindowManager::updateDpi(float scale) { +void WindowManager::updateDpi(float scale) +{ scale = std::clamp(scale, 1.0f, 4.0f); - if (scale == dpi_scale_) return; + if (scale == dpi_scale_) + return; dpi_scale_ = scale; dpi_changed_ = true; applyNativeCaption(); } -void WindowManager::applyNativeCaption() const { +void WindowManager::applyNativeCaption() const +{ #ifdef _WIN32 + if (!window_) + return; + const HWND hwnd = glfwGetWin32Window(window_); + if (!hwnd) + return; + const BOOL dark = TRUE; - DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)); // DWMWA_USE_IMMERSIVE_DARK_MODE - const DWORD square_corners = 1; // DWMWCP_DONOTROUND - DwmSetWindowAttribute(hwnd, 33, &square_corners, sizeof(square_corners)); + + // DWMWA_USE_IMMERSIVE_DARK_MODE + DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)); + + DWORD corner_pref = 0; + + switch (corner_mode_) + { + case WindowCornerMode::Default: + corner_pref = 0; // DWMWCP_DEFAULT + break; + + case WindowCornerMode::DoNotRound: + corner_pref = 1; // DWMWCP_DONOTROUND + break; + + case WindowCornerMode::Round: + corner_pref = 2; // DWMWCP_ROUND + break; + + case WindowCornerMode::RoundSmall: + corner_pref = 3; // DWMWCP_ROUNDSMALL + break; + } + + // DWMWA_WINDOW_CORNER_PREFERENCE + DwmSetWindowAttribute(hwnd, 33, &corner_pref, sizeof(corner_pref)); // Windows 11 caption customization. Older versions safely ignore these. - const COLORREF caption = RGB(32, 32, 32); + const COLORREF caption = static_cast(caption_color_); const COLORREF border = RGB(51, 55, 63); const COLORREF text = RGB(199, 203, 209); - DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption)); // DWMWA_CAPTION_COLOR - DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border)); // DWMWA_BORDER_COLOR - DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text)); // DWMWA_TEXT_COLOR + + // DWMWA_BORDER_COLOR + DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border)); + + // DWMWA_CAPTION_COLOR + DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption)); + + // DWMWA_TEXT_COLOR + DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text)); #endif } + +void WindowManager::applyNativeIcons() +{ +#ifdef _WIN32 + destroyNativeIcons(); + const HWND hwnd = glfwGetWin32Window(window_); + const auto asset_dir = std::filesystem::path(GITREE_IMAGE_ASSET_DIR); + window_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 32, true); + taskbar_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 64, true); + + if (window_icon_) + { + SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(window_icon_)); + SendMessageW(hwnd, WM_SETICON, ICON_SMALL2, reinterpret_cast(window_icon_)); + } + if (taskbar_icon_) + { + SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(taskbar_icon_)); + } +#endif +} + +void WindowManager::destroyNativeIcons() +{ +#ifdef _WIN32 + if (window_icon_) + DestroyIcon(static_cast(window_icon_)); + if (taskbar_icon_) + DestroyIcon(static_cast(taskbar_icon_)); +#endif + window_icon_ = nullptr; + taskbar_icon_ = nullptr; +} + +void WindowManager::setCornerMode(WindowCornerMode mode) +{ + corner_mode_ = mode; + applyNativeCaption(); +} + +void WindowManager::setCaptionColor(std::uint32_t color) +{ + caption_color_ = color; + applyNativeCaption(); +} diff --git a/src/managers/window_manager.h b/src/managers/window_manager.h index 2707b57..669af55 100644 --- a/src/managers/window_manager.h +++ b/src/managers/window_manager.h @@ -1,31 +1,62 @@ #pragma once +#include + struct GLFWwindow; -class WindowManager { +class WindowManager +{ +public: + enum class WindowCornerMode + { + Default, + DoNotRound, + Round, + RoundSmall + }; + 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(); + void setCornerMode(WindowCornerMode mode); + [[nodiscard]] WindowCornerMode cornerMode() const { return corner_mode_; } + + void setCaptionColor(std::uint32_t color); + [[nodiscard]] std::uint32_t captionColor() const { return caption_color_; } + 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; + void applyNativeIcons(); + void destroyNativeIcons(); + + GLFWwindow *window_ = nullptr; + void *window_icon_ = nullptr; + void *taskbar_icon_ = nullptr; - GLFWwindow* window_ = nullptr; float dpi_scale_ = 1.0f; bool dpi_changed_ = false; + + WindowCornerMode corner_mode_ = WindowCornerMode::Round; + + // 0x00BBGGRR, same layout Windows COLORREF uses. + std::uint32_t caption_color_ = 0x00201B19; }; diff --git a/src/models/repository.h b/src/models/repository.h index 8c45f81..476e38d 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,39 @@ struct WorkingFile { bool staged = false; }; +struct BranchDivergence { + size_t ahead = 0; + size_t behind = 0; +}; + +enum class ToolbarHistoryActionKind { none, checkout, commit }; + +struct ToolbarHistoryAction { + ToolbarHistoryActionKind kind = ToolbarHistoryActionKind::none; + bool available = false; + std::string tooltip; + std::string source; + std::string target; + std::string summary; +}; + 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 +74,19 @@ 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; + ToolbarHistoryAction undo_action; + ToolbarHistoryAction redo_action; RepositoryView() = default; ~RepositoryView() { close(); } diff --git a/src/ui/diff_viewer.cpp b/src/ui/diff_viewer.cpp index a8b180a..3409d79 100644 --- a/src/ui/diff_viewer.cpp +++ b/src/ui/diff_viewer.cpp @@ -1,17 +1,22 @@ #include "ui/diff_viewer.h" +#include "managers/avatar_cache.h" #include "managers/git_manager.h" #include "models/repository.h" -#include +#include #include -#include #include #include +#include +#include #include #include +#include +#include #include +#include namespace { float scaled(float value, float scale) { return value * scale; } @@ -38,41 +43,450 @@ 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; +}; + +[[maybe_unused]] 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); +} + +[[maybe_unused]] 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"}); +} + +bool isBuiltin(SyntaxLanguage language, std::string_view word) { + switch (language) { + case SyntaxLanguage::cpp: + return wordIs(word, {"std", "move", "forward", "make_shared", "make_unique", "optional", "string_view", "vector"}); + case SyntaxLanguage::csharp: + return wordIs(word, {"Console", "DateTime", "Dictionary", "Enumerable", "List", "Task", "ValueTask"}); + case SyntaxLanguage::lua: + return wordIs(word, {"assert", "error", "ipairs", "io", "math", "os", "pairs", "pcall", "print", "require", "string", "table", "tonumber", "tostring", "type"}); + case SyntaxLanguage::python: + return wordIs(word, {"abs", "all", "any", "dict", "enumerate", "filter", "float", "int", "len", "list", "map", "max", "min", "object", "open", "print", "range", "reversed", "set", "str", "sum", "super", "tuple", "zip"}); + case SyntaxLanguage::javascript: + return wordIs(word, {"Array", "Boolean", "console", "Date", "Error", "JSON", "Map", "Math", "Number", "Object", "process", "Promise", "RegExp", "require", "Set", "String", "Symbol"}); + default: + return false; + } +} + +bool isLiteral(std::string_view word) { + return wordIs(word, {"false", "False", "nil", "null", "nullptr", "None", "true", "True", "undefined"}); +} + +bool isDeclarationKeyword(std::string_view word) { + return wordIs(word, {"class", "concept", "delegate", "enum", "interface", "namespace", "record", "struct", "type"}); +} + +bool isFunctionDeclarationKeyword(std::string_view word) { + return word == "def" || word == "function"; +} + +bool isMacroName(std::string_view word) { + bool has_letter = false; + for (const unsigned char character : word) { + if (std::isalpha(character)) { + has_letter = true; + if (std::islower(character)) return false; + } else if (!std::isdigit(character) && character != '_') { + return false; + } + } + return has_letter && word.size() > 1; +} + +[[maybe_unused]] 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); + constexpr ImU32 member = IM_COL32(122, 184, 225, 255); + constexpr ImU32 builtin = IM_COL32(103, 172, 232, 255); + constexpr ImU32 constant = IM_COL32(214, 139, 102, 255); + constexpr ImU32 operator_color = IM_COL32(105, 180, 210, 255); + constexpr ImU32 decorator = IM_COL32(226, 190, 105, 255); + constexpr ImU32 macro = IM_COL32(215, 128, 180, 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; + std::string_view previous_word; + 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 (text[cursor] == '@' && cursor + 1 < text.size() && + (std::isalpha(static_cast(text[cursor + 1])) || text[cursor + 1] == '_')) { + size_t end = cursor + 2; + while (end < text.size() && (std::isalnum(static_cast(text[end])) || + text[end] == '_' || text[end] == '.')) ++end; + drawSpan(cursor, end, decorator); + 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; + size_t previous = cursor; + while (previous > 0 && std::isspace(static_cast(text[previous - 1]))) --previous; + const bool member_access = previous > 0 && (text[previous - 1] == '.' || + (text[previous - 1] == '>' && previous > 1 && text[previous - 2] == '-') || + (text[previous - 1] == ':' && previous > 1 && text[previous - 2] == ':')); + const bool declared_name = isDeclarationKeyword(previous_word); + const bool declared_function = isFunctionDeclarationKeyword(previous_word); + const bool capitalized_type = + (language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) && + std::isupper(static_cast(word.front())) && !member_access && + (next >= text.size() || text[next] != '('); + const bool likely_type = declared_name || isTypeWord(language, word) || + capitalized_type; + const ImU32 color = isLiteral(word) ? constant : isKeyword(language, word) ? keyword : + declared_function ? function : declared_name || likely_type ? type : + isMacroName(word) ? macro : isBuiltin(language, word) ? builtin : member_access ? + (next < text.size() && text[next] == '(' ? function : member) : + next < text.size() && text[next] == '(' ? function : normal; + drawSpan(cursor, end, color); + previous_word = word; + cursor = end; + continue; + } + if (std::string_view("+-*/%=!<>&|^~?:").find(text[cursor]) != std::string_view::npos) { + size_t end = cursor + 1; + while (end < text.size() && + std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos) ++end; + drawSpan(cursor, end, operator_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] == '-') || text[end] == '@' || + std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos; + if (token_start) break; + ++end; + } + drawSpan(cursor, end, normal); + cursor = end; + } } bool compactButton(const char* label, bool active = false) { if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f)); - const bool clicked = ImGui::SmallButton(label); + const bool icon_only = label && std::strlen(label) <= 4; + const bool clicked = icon_only + ? ImGui::Button(label, {ImGui::GetFrameHeight(), ImGui::GetFrameHeight()}) + : ImGui::SmallButton(label); if (active) ImGui::PopStyleColor(); return clicked; } + +std::string joinLines(const std::vector& lines) { + std::string text; + for (size_t index = 0; index < lines.size(); ++index) { + if (index) text += '\n'; + text += lines[index]; + } + return text; +} + +bool drawSelectableTextBlock(const char* id, const std::string& text, const ImVec2& size) { + std::vector buffer(text.begin(), text.end()); + buffer.push_back('\0'); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.06f, 0.07f, 0.09f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.18f, 0.20f, 0.24f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4.0f, 4.0f}); + const bool changed = ImGui::InputTextMultiline(id, buffer.data(), buffer.size(), size, + ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_AllowTabInput); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(2); + return changed; +} + +struct MinimapEntry { + size_t length = 0; + ImU32 color = IM_COL32(120, 126, 136, 255); +}; + +void drawMinimap(const std::vector& entries, float scale, + float rendered_content_height, float visible_start, float visible_height) { + const ImVec2 minimum = ImGui::GetCursorScreenPos(); + const ImVec2 size = ImGui::GetContentRegionAvail(); + ImGui::InvisibleButton("##code_minimap", size); + ImDrawList* draw = ImGui::GetWindowDrawList(); + draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(38, 40, 47, 255)); + draw->AddRectFilled({minimum.x + size.x - scaled(10.0f, scale), minimum.y}, + {minimum.x + size.x, minimum.y + size.y}, IM_COL32(64, 66, 74, 255)); + + if (entries.empty() || size.y <= 2.0f) return; + + const float content_left = minimum.x + scaled(4.0f, scale); + const float content_right = minimum.x + size.x - scaled(14.0f, scale); + const float content_width = std::max(1.0f, content_right - content_left); + const float line_step = size.y / static_cast(entries.size()); + const float line_height = std::max(1.0f, std::min(scaled(2.0f, scale), line_step)); + constexpr float max_reference_length = 160.0f; + for (size_t index = 0; index < entries.size(); ++index) { + const float normalized = std::clamp(static_cast(entries[index].length) / max_reference_length, 0.08f, 1.0f); + const float width = content_width * normalized; + const float y = minimum.y + index * line_step; + draw->AddRectFilled({content_left, y}, {content_left + width, y + line_height}, + entries[index].color, scaled(1.0f, scale)); + } + + const float content_height = std::max(rendered_content_height, 1.0f); + const float clamped_visible_height = std::clamp(visible_height, 0.0f, content_height); + const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, content_height - clamped_visible_height)); + const float viewport_height = std::clamp(size.y * (clamped_visible_height / content_height), + scaled(18.0f, scale), size.y); + const float viewport_y = minimum.y + size.y * (clamped_visible_start / content_height); + draw->AddRectFilled({minimum.x + scaled(1.0f, scale), viewport_y}, + {minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height}, + IM_COL32(120, 146, 198, 45), scaled(2.0f, scale)); + draw->AddRect({minimum.x + scaled(1.0f, scale), viewport_y}, + {minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height}, + IM_COL32(120, 146, 198, 160), scaled(2.0f, scale)); +} + +void drawCodeLine(const std::string& text, SyntaxLanguage language, SyntaxState& syntax, + float scale, ImU32 background = IM_COL32(0, 0, 0, 0), float left_gutter = 0.0f, + float minimum_width = 0.0f) { + const float row_height = scaled(21.0f, scale); + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width = std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale)); + ImGui::InvisibleButton("##code_line", {width, row_height}); + const ImVec2 minimum = ImGui::GetItemRectMin(); + const ImVec2 maximum = ImGui::GetItemRectMax(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + if ((background & IM_COL32_A_MASK) != 0) + draw->AddRectFilled(minimum, maximum, background); + drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale)}, text, language, syntax); +} + +void drawCodeLineNumber(int value, float x, float y, ImU32 color) { + if (value <= 0) return; + const std::string text = std::to_string(value); + ImGui::GetWindowDrawList()->AddText({x, y}, color, text.c_str()); +} } 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 +532,73 @@ 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-mail") { + line.email = value; + if (line.email.size() >= 2 && line.email.front() == '<' && line.email.back() == '>') + line.email = line.email.substr(1, line.email.size() - 2); + } + 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 +629,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,26 +642,36 @@ 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); } } -void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice) { +void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars, + float scale, ImFont* code_font, std::string& notice) { + (void)avatars; ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {scaled(8, scale), scaled(5, scale)}); ImGui::BeginChild("diff_viewer", {-1, -1}, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN); + ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_TB_PEN); ImGui::SameLine(0, scaled(7, scale)); 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,63 +686,74 @@ 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(); + if (compactButton(ICON_TB_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)) { + if (compactButton(ICON_TB_TRASH_CAN)) { if (manager.discardFile(repository, path_, notice)) close(); } ImGui::PopStyleColor(); } - ImGui::Separator(); + ImGui::Separator(); } - ImGui::BeginChild("diff_content", {-1, -1}, ImGuiChildFlags_None, - ImGuiWindowFlags_HorizontalScrollbar); + const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file; + const float minimap_width = scaled(56.0f, scale); + float main_scroll_y = 0.0f; + float main_window_height = 0.0f; + float rendered_content_height = 0.0f; + std::vector minimap_entries; + if (show_minimap) { + minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)}); + if (mode_ == Mode::diff) { + for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) { + if (hunk_index > 0) { + minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)}); + minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)}); + } + minimap_entries.push_back({hunks_[hunk_index].header.size(), IM_COL32(128, 133, 141, 255)}); + for (const Line& line : hunks_[hunk_index].lines) { + const ImU32 color = line.kind == LineKind::added ? IM_COL32(87, 190, 112, 255) : + line.kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : + IM_COL32(112, 118, 128, 255); + minimap_entries.push_back({line.text.size() + 1, color}); + } + minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)}); + } + } else { + minimap_entries.reserve(file_lines_.size()); + for (const std::string& line : file_lines_) + minimap_entries.push_back({line.size(), IM_COL32(112, 118, 128, 255)}); + } + } + + ImGui::BeginChild("diff_content_main", + show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1}, + ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + const bool use_code_font = code_font && mode_ != Mode::history; + if (use_code_font) ImGui::PushFont(code_font, 0.0f); 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) { - ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height}); - const ImVec2 minimum = ImGui::GetItemRectMin(); - const ImVec2 maximum = ImGui::GetItemRectMax(); - ImDrawList* draw = ImGui::GetWindowDrawList(); - if (kind == LineKind::added) draw->AddRectFilled(minimum, maximum, IM_COL32(31, 65, 43, 225)); - else if (kind == LineKind::removed) draw->AddRectFilled(minimum, maximum, IM_COL32(70, 38, 40, 225)); - draw->AddRectFilled(minimum, {minimum.x + number_width * 2, maximum.y}, IM_COL32(31, 34, 40, 255)); - char old_buffer[16]{}; - char new_buffer[16]{}; - if (old_number) std::snprintf(old_buffer, sizeof(old_buffer), "%d", old_number); - if (new_number) std::snprintf(new_buffer, sizeof(new_buffer), "%d", new_number); - draw->AddText({minimum.x + scaled(5, scale), minimum.y + scaled(2, scale)}, - IM_COL32(158, 164, 174, 255), old_buffer); - draw->AddText({minimum.x + number_width + scaled(5, scale), minimum.y + scaled(2, scale)}, - IM_COL32(158, 164, 174, 255), new_buffer); - const char marker = kind == LineKind::added ? '+' : kind == LineKind::removed ? '-' : ' '; - char marker_text[2]{marker, 0}; - 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()); - }; + const float content_width = std::max(ImGui::GetContentRegionAvail().x, scaled(200.0f, scale)); + const SyntaxLanguage language = languageForPath(path_); + + // 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."); @@ -267,12 +762,19 @@ 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)); + if (hunk_index > 0) { + ImGui::Dummy({0.0f, scaled(11.0f, scale)}); + ImGui::Separator(); + ImGui::Dummy({0.0f, scaled(8.0f, scale)}); + } + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.62f, 0.64f, 0.68f, 1.0f)); 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 +788,27 @@ 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; } + ImGui::Separator(); + ImGui::Dummy({0.0f, scaled(4.0f, scale)}); + const float number_column = scaled(44.0f, scale); + const float code_gutter = number_column * 2.0f + scaled(14.0f, scale); for (const Line& line : hunks_[hunk_index].lines) { ImGui::PushID(line_id++); - draw_line(line.text, line.old_number, line.new_number, line.kind); + const ImVec2 line_minimum = ImGui::GetCursorScreenPos(); + const ImU32 background = line.kind == LineKind::added ? IM_COL32(30, 68, 46, 170) : + line.kind == LineKind::removed ? IM_COL32(86, 38, 42, 170) : IM_COL32(0, 0, 0, 0); + drawCodeLine(line.text, language, + line.kind == LineKind::removed ? old_syntax : new_syntax, + scale, background, code_gutter, content_width); + const float text_y = line_minimum.y + scaled(2.0f, scale); + drawCodeLineNumber(line.old_number, line_minimum.x + scaled(6.0f, scale), text_y, + IM_COL32(126, 132, 142, 255)); + drawCodeLineNumber(line.new_number, line_minimum.x + number_column + scaled(6.0f, scale), text_y, + IM_COL32(126, 132, 142, 255)); ImGui::PopID(); } ImGui::PopID(); @@ -303,18 +819,57 @@ 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) { + std::ostringstream blame_text; + for (size_t index = 0; index < blame_lines_.size(); ++index) { + const BlameLine& line = blame_lines_[index]; + if (index) blame_text << '\n'; + blame_text << line.line_number << " "; + if (line.show_attribution) { + blame_text << line.summary; + if (!line.date.empty()) blame_text << " " << line.date; + blame_text << " "; + } else { + blame_text << "| "; + } + blame_text << line.text; + } + drawSelectableTextBlock("##blame_text", blame_text.str(), {-1, -1}); + 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_; - 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); - ImGui::PopID(); + const std::vector* lines = mode_ == Mode::file ? &file_lines_ : &history_lines_; + if (mode_ == Mode::file) { + SyntaxState syntax; + const float code_gutter = scaled(52.0f, scale); + for (size_t index = 0; index < lines->size(); ++index) { + ImGui::PushID(static_cast(index)); + const ImVec2 line_minimum = ImGui::GetCursorScreenPos(); + drawCodeLine((*lines)[index], language, syntax, scale, IM_COL32(0, 0, 0, 0), + code_gutter, content_width); + drawCodeLineNumber(static_cast(index + 1), line_minimum.x + scaled(6.0f, scale), + line_minimum.y + scaled(2.0f, scale), IM_COL32(126, 132, 142, 255)); + ImGui::PopID(); + } + } else { + drawSelectableTextBlock("##history_text", joinLines(*lines), {-1, -1}); } if (lines->empty()) ImGui::TextDisabled("No data is available for this view."); } + main_scroll_y = ImGui::GetScrollY(); + main_window_height = ImGui::GetWindowHeight(); + rendered_content_height = ImGui::GetCursorPosY(); + if (use_code_font) ImGui::PopFont(); ImGui::EndChild(); + if (show_minimap) { + ImGui::SameLine(0, scaled(6.0f, scale)); + const float minimap_line_height = scaled(2.0f, scale); + const float minimap_height = std::min(main_window_height, + std::max(scaled(36.0f, scale), static_cast(minimap_entries.size()) * minimap_line_height + scaled(8.0f, scale))); + ImGui::BeginChild("diff_content_minimap", {minimap_width, minimap_height}, ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + drawMinimap(minimap_entries, scale, rendered_content_height, main_scroll_y, main_window_height); + ImGui::EndChild(); + } ImGui::EndChild(); ImGui::PopStyleVar(); } diff --git a/src/ui/diff_viewer.h b/src/ui/diff_viewer.h index a703919..c3a2b78 100644 --- a/src/ui/diff_viewer.h +++ b/src/ui/diff_viewer.h @@ -4,15 +4,20 @@ #include class GitManager; +class AvatarCache; struct RepositoryView; +struct ImFont; 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); + void draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars, + float scale, ImFont* code_font, std::string& notice); private: enum class Mode { diff, file, blame, history }; @@ -29,17 +34,29 @@ private: std::vector lines; std::string patch; }; + struct BlameLine { + std::string hash; + std::string author; + std::string email; + 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..3ad5a43 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 #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" @@ -21,7 +24,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -36,14 +42,15 @@ size_t g_active_tab = 0; RepositoryView* g_tab_to_select = nullptr; UserData* g_user_data = nullptr; AvatarCache* g_avatar_cache = nullptr; -std::array g_path{}; std::array g_filter{}; std::array g_branch_filter{}; std::string g_notice; bool g_init_popup = false; +bool g_clone_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; @@ -51,36 +58,175 @@ bool g_discard_all_popup = false; std::array g_git_name{}; std::array g_git_value{}; std::array g_git_path{}; +std::array g_repository_filter{}; +std::array g_create_repository_name{}; +std::array g_create_repository_parent{}; +std::array g_create_repository_branch{}; +std::array g_clone_repository_parent{}; +std::array g_clone_repository_url{}; std::string g_git_target; std::array g_inline_branch_name{}; std::string g_inline_branch_target; +std::string g_requested_branch_checkout; +std::string g_running_branch_checkout; +std::string g_pending_commit_jump; +RepositoryView* g_expanded_refs_repository = nullptr; +int g_expanded_refs_commit = -1; 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_summary{}; std::array g_commit_description{}; +std::array g_commit_search{}; +std::string g_previous_commit_search; +RepositoryView* g_commit_search_repository = nullptr; +int g_commit_search_match = -1; +bool g_commit_search_open = false; +bool g_focus_commit_search = false; float g_ui_scale = 1.0f; +int g_zoom_percent = 100; +bool g_zoom_reload_requested = false; +bool g_reset_repository_view = false; float g_sidebar_width = 230.0f; +bool g_sidebar_collapsed = false; +bool g_sidebar_collapsed_before_viewer = false; +bool g_sidebar_auto_collapsed = false; +bool g_previous_code_viewer_open = false; float g_details_width = 368.0f; +float g_commit_message_height = 125.0f; +float g_working_composer_height = 320.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; +ImFont* g_regular_font = nullptr; +ImFont* g_bold_font = nullptr; +ImFont* g_code_font = nullptr; float g_outline_icon_size = 15.0f; -constexpr const char* ICON_TB_CHECK = "\xee\xa9\x9e"; -constexpr const char* ICON_TB_CLOUD = "\xee\xa9\xb6"; -constexpr const char* ICON_TB_DEVICE_LAPTOP = "\xee\xad\xa4"; -constexpr const char* ICON_TB_TAG = "\xee\xa4\x80"; +enum class ToolbarActionRequest { none, pull, push }; +ToolbarActionRequest g_pending_toolbar_action = ToolbarActionRequest::none; +ToolbarActionRequest g_running_toolbar_action = ToolbarActionRequest::none; + 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); + +float combined_ui_scale(float dpi_scale, int zoom_percent) { + const float zoom_scale = static_cast(zoom_percent) / 100.0f; + return std::clamp(dpi_scale + zoom_scale - 1.0f, 0.80f, 4.0f); +} + +bool text_height_checkbox(const char* label, bool* value) { + const ImVec2 padding = ImGui::GetStyle().FramePadding; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {padding.x, 0.0f}); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, ui(1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, IM_COL32(70, 76, 87, 255)); + const bool changed = ImGui::Checkbox(label, value); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + ImGui::PopStyleVar(); + return changed; +} + +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; +} + +bool run_repository_action(const std::vector& arguments, const std::string& success_notice, + const std::string& focus_commit = {}) { + std::string output; + if (!g_git_manager->captureGit(repo(), arguments, output, g_notice)) return false; + if (!g_git_manager->reload(repo(), g_notice)) return false; + if (!focus_commit.empty()) g_pending_commit_jump = focus_commit; + g_notice = success_notice; + 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; @@ -112,27 +258,73 @@ void persist_repository_session() { g_user_data->setRepositorySession(std::move(paths), g_active_tab); } +void clear_sidebar_filter() { + g_filter.fill('\0'); +} + +void request_branch_checkout(const std::string& branch) { + g_requested_branch_checkout = branch; +} + +void reset_repository_view() { + if (g_tabs.empty()) return; + RepositoryView& view = repo(); + view.selected_commit = 0; + view.scroll_to_commit = view.commits.empty() ? -1 : 0; + g_diff_viewer.close(); + g_commit_search_open = false; + g_commit_search_repository = nullptr; + g_commit_search.fill('\0'); + g_previous_commit_search.clear(); + g_commit_search_match = -1; + g_filter.fill('\0'); + g_branch_filter.fill('\0'); + g_view_all_files = false; + g_file_view_mode = FileViewMode::path; + g_expanded_refs_repository = nullptr; + g_expanded_refs_commit = -1; + cancel_inline_branch(); + g_requested_branch_checkout.clear(); + g_running_branch_checkout.clear(); + g_reset_repository_view = true; +} + +void activate_repository_tab(size_t index) { + if (index >= g_tabs.size() || index == g_active_tab) return; + g_active_tab = index; + g_tab_to_select = g_tabs[index].get(); + reset_repository_view(); + persist_repository_session(); +} + void create_new_tab(bool persist = true) { g_tabs.push_back(std::make_unique()); g_active_tab = g_tabs.size() - 1; g_tab_to_select = g_tabs.back().get(); + reset_repository_view(); if (persist) persist_repository_session(); } void close_tab(size_t index) { if (index >= g_tabs.size()) return; + const bool closing_active_tab = index == g_active_tab; if (g_inline_branch_repository == g_tabs[index].get()) cancel_inline_branch(); if (g_user_data && !g_tabs[index]->path.empty()) g_user_data->addRecentlyClosed(g_tabs[index]->path); g_tabs.erase(g_tabs.begin() + static_cast(index)); if (g_tabs.empty()) create_new_tab(); else if (g_active_tab >= g_tabs.size()) g_active_tab = g_tabs.size() - 1; else if (index < g_active_tab) --g_active_tab; + if (closing_active_tab && !g_tabs.empty()) reset_repository_view(); persist_repository_session(); } bool open_repository(const char* path) { const bool opened = g_git_manager->openRepository(repo(), path, g_notice); - if (opened) persist_repository_session(); + if (opened) { + if (g_user_data && path && *path) g_user_data->addRecentRepository(path); + reset_repository_view(); + persist_repository_session(); + } return opened; } @@ -140,7 +332,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; @@ -159,24 +351,61 @@ void pick_and_open_repository() { open_repository(path.c_str()); } -bool init_repository(const char* path) { - const bool initialized = g_git_manager->initializeRepository(repo(), path, g_notice); - if (initialized) persist_repository_session(); +bool init_repository(const char* path, const char* initial_branch = "main") { + const bool initialized = g_git_manager->initializeRepository(repo(), path, initial_branch, g_notice); + if (initialized) { + if (g_user_data && path && *path) g_user_data->addRecentRepository(path); + persist_repository_session(); + } return initialized; } +std::filesystem::path default_repository_parent() { + std::filesystem::path documents = izo::GetKnownPath(izo::KnownPath::Documents); + const std::filesystem::path github = documents / "GitHub"; + std::error_code error; + return std::filesystem::is_directory(github, error) ? github : documents; +} + +template +void set_path_buffer(std::array& buffer, const std::filesystem::path& path) { + const auto utf8 = path.u8string(); + const std::string value(reinterpret_cast(utf8.data()), utf8.size()); + std::snprintf(buffer.data(), buffer.size(), "%s", value.c_str()); +} + +bool pick_folder_into(std::array& buffer, const char* title) { + izo::DialogOptions options; + options.title = title; + options.initialPath = buffer[0] ? std::filesystem::path(buffer.data()) : default_repository_parent(); + const izo::DialogResult result = izo::PickFolder(options); + if (result.status == izo::DialogStatus::Cancelled) return false; + if (result.status == izo::DialogStatus::Error || result.paths.empty()) { + g_notice = result.errorMessage.empty() ? "Unable to select a folder" : result.errorMessage; + return false; + } + set_path_buffer(buffer, result.paths.front()); + return true; +} + void apply_style(float scale) { g_ui_scale = 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}; @@ -190,10 +419,10 @@ void apply_style(float scale) { }; style.Colors[ImGuiCol_Text] = color(218, 221, 226); style.Colors[ImGuiCol_TextDisabled] = color(154, 159, 168); - style.Colors[ImGuiCol_WindowBg] = color(28, 30, 35); - style.Colors[ImGuiCol_MenuBarBg] = color(31, 31, 31); - style.Colors[ImGuiCol_ChildBg] = color(28, 30, 35); - style.Colors[ImGuiCol_PopupBg] = color(39, 42, 49); + style.Colors[ImGuiCol_WindowBg] = color(22, 24, 29); + style.Colors[ImGuiCol_MenuBarBg] = color(25, 27, 32); + style.Colors[ImGuiCol_ChildBg] = color(22, 24, 29); + style.Colors[ImGuiCol_PopupBg] = color(34, 37, 44); style.Colors[ImGuiCol_Border] = color(55, 59, 67); style.Colors[ImGuiCol_BorderShadow] = color(0, 0, 0, 0); style.Colors[ImGuiCol_FrameBg] = color(31, 34, 40); @@ -205,17 +434,19 @@ void apply_style(float scale) { style.Colors[ImGuiCol_Header] = color(37, 51, 79); style.Colors[ImGuiCol_HeaderHovered] = color(45, 58, 84); style.Colors[ImGuiCol_HeaderActive] = color(49, 64, 91); - 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_Tab] = color(43, 47, 56); + style.Colors[ImGuiCol_TabHovered] = color(51, 56, 66); + style.Colors[ImGuiCol_TabSelected] = color(58, 63, 74); + style.Colors[ImGuiCol_TabDimmed] = color(43, 47, 56); + style.Colors[ImGuiCol_TabDimmedSelected] = color(53, 58, 68); + 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_TableHeaderBg] = color(30, 33, 39); + style.Colors[ImGuiCol_TableRowBg] = color(22, 24, 29); + style.Colors[ImGuiCol_TableRowBgAlt] = color(22, 24, 29); 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); @@ -225,28 +456,52 @@ void apply_style(float scale) { void load_fonts(float scale) { ImGuiIO& io = ImGui::GetIO(); io.Fonts->Clear(); + g_regular_font = nullptr; + g_bold_font = nullptr; + g_code_font = nullptr; + g_outline_icon_font = nullptr; ImFontConfig config; config.OversampleH = 2; config.OversampleV = 2; config.PixelSnapH = false; config.RasterizerDensity = 1.0f; const float size = 18.0f * scale; - if (!io.Fonts->AddFontFromFileTTF(GITREE_ASSET_DIR "/OpenSans-Regular.ttf", size, &config)) - io.Fonts->AddFontDefault(); + g_regular_font = io.Fonts->AddFontFromFileTTF(GITREE_ASSET_DIR "/Inter-Regular.ttf", size, &config); + if (!g_regular_font) g_regular_font = io.Fonts->AddFontDefault(); + io.FontDefault = g_regular_font; - static constexpr ImWchar icon_ranges[] = {ICON_MIN_FA, ICON_MAX_16_FA, 0}; - ImFontConfig icon_config; - icon_config.MergeMode = true; - icon_config.PixelSnapH = true; - icon_config.GlyphMinAdvanceX = size; - io.Fonts->AddFontFromFileTTF( - GITREE_ASSET_DIR "/fa-solid-900.ttf", size, &icon_config, icon_ranges); + static constexpr ImWchar icon_ranges[] = {ICON_MIN_TB, ICON_MAX_TB, 0}; + const float icon_size = 14.0f * scale; + const auto merge_icons = [&] { + ImFontConfig icon_config; + icon_config.MergeMode = true; + icon_config.PixelSnapH = true; + icon_config.GlyphMinAdvanceX = icon_size; + icon_config.GlyphOffset.y = 1.0f * scale; + io.Fonts->AddFontFromFileTTF( + GITREE_ASSET_DIR "/tabler-icons-outline.ttf", icon_size, &icon_config, icon_ranges); + }; + merge_icons(); + + ImFontConfig bold_config = config; + g_bold_font = io.Fonts->AddFontFromFileTTF( + GITREE_ASSET_DIR "/Inter-SemiBold.ttf", size, &bold_config); + if (!g_bold_font) g_bold_font = g_regular_font; + else merge_icons(); + + ImFontConfig code_config = config; + g_code_font = io.Fonts->AddFontFromFileTTF( + "C:/Windows/Fonts/CascadiaMono.ttf", size, &code_config); + if (!g_code_font) + g_code_font = io.Fonts->AddFontFromFileTTF("C:/Windows/Fonts/CascadiaCode.ttf", size, &code_config); + if (!g_code_font) g_code_font = g_regular_font; + else merge_icons(); static constexpr ImWchar outline_ranges[] = { - 0xE900, 0xE900, 0xEA5E, 0xEA5E, 0xEA76, 0xEA76, 0xEB64, 0xEB64, + 0xEF86, 0xEF86, 0, }; ImFontConfig outline_config; @@ -257,7 +512,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, bool bold_text = false) { ImGui::PushID(id); const ImGuiID state_id = ImGui::GetID("collapse_state"); ImGuiStorage* storage = ImGui::GetStateStorage(); @@ -270,20 +526,34 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); - if (ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(51, 55, 63, 220)); - constexpr ImU32 icon_color = IM_COL32(144, 150, 160, 255); - constexpr ImU32 text_color = IM_COL32(207, 211, 218, 255); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) draw->AddRectFilled(minimum, maximum, IM_COL32(51, 55, 63, 220)); + constexpr ImU32 icon_color = IM_COL32(112, 118, 128, 255); + const ImU32 chevron_color = hovered + ? IM_COL32(218, 223, 231, 255) : IM_COL32(126, 132, 142, 255); + const ImU32 text_color = hovered + ? IM_COL32(238, 241, 246, 255) : IM_COL32(190, 195, 204, 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); + const float icon_size = ImGui::GetFontSize() * 0.90f; + const float icon_y = minimum.y + (size.y - icon_size) * 0.5f; + if (show_chevron) + draw->AddText(g_regular_font, icon_size, {minimum.x + ui(3.0f), icon_y}, chevron_color, + open ? ICON_TB_CHEVRON_DOWN : ICON_TB_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_TB_FOLDER + ? ICON_TB_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(g_regular_font, icon_size, {minimum.x + ui(icon_x), icon_y}, + icon_color, rendered_icon); + draw->AddText(bold_text ? g_bold_font : g_regular_font, ImGui::GetFontSize(), + {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(bold_text ? g_bold_font : g_regular_font, ImGui::GetFontSize(), + {minimum.x + ui(show_chevron ? 20.0f : 3.0f), y}, text_color, label.c_str()); } if (clicked) { open = !open; @@ -293,18 +563,44 @@ 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(112, 118, 128, 255), + const std::string& trailing_text = {}, bool active_branch = false) { ImGui::PushID(id.c_str()); const bool clicked = ImGui::InvisibleButton("##sidebar_item", {-1, ui(24.0f)}); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); - if (ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(51, 55, 63, 210)); + const bool hovered = ImGui::IsItemHovered(); + if (active_branch) { + const ImVec2 fill_min{ImGui::GetWindowPos().x, minimum.y}; + draw->AddRectFilled(fill_min, maximum, + hovered ? IM_COL32(69, 122, 85, 240) : IM_COL32(56, 103, 72, 228), ui(3.0f)); + } + else if (hovered) + 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 icon_size = ImGui::GetFontSize() * 0.90f; + const float icon_y = minimum.y + (maximum.y - minimum.y - icon_size) * 0.5f; + 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(g_regular_font, icon_size, {minimum.x + ui(3.0f), icon_y}, + status_color, status_icon); + draw->AddText(g_regular_font, icon_size, + {minimum.x + ui(3.0f) + content_offset, icon_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; } @@ -319,7 +615,7 @@ bool sidebar_section_is_open(const char* id) { bool sidebar_section_header(const char* label, int count, const char* add_tooltip, const char* add_notice) { const ImVec2 header_min = ImGui::GetCursorScreenPos(); const float header_width = ImGui::GetContentRegionAvail().x; - const bool open = sidebar_collapse_row(label, label, true, ui(62.0f)); + const bool open = sidebar_collapse_row(label, label, true, ui(62.0f), true, true); const ImVec2 mouse = ImGui::GetIO().MousePos; const bool header_hovered = ImGui::IsWindowHovered() && mouse.x >= header_min.x && mouse.x <= header_min.x + header_width && mouse.y >= header_min.y && @@ -352,7 +648,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; @@ -392,9 +693,9 @@ enum class SidebarItemKind { worktree, submodule }; void sidebar_item_context(const std::string& item, SidebarItemKind kind) { if (!ImGui::BeginPopupContextItem()) return; - if (kind == SidebarItemKind::submodule && ImGui::MenuItem(ICON_FA_DOWNLOAD " Pull / Update")) + if (kind == SidebarItemKind::submodule && ImGui::MenuItem(ICON_TB_DOWNLOAD " Pull / Update")) g_git_manager->updateSubmodule(repo(), item, g_notice); - if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Open in file manager")) { + if (ImGui::MenuItem(ICON_TB_FOLDER_OPEN " Open in file manager")) { std::string error; const std::filesystem::path path = kind == SidebarItemKind::worktree ? g_git_manager->worktreePath(repo(), item, error) @@ -402,7 +703,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_TB_COPY " Copy name")) copy_to_clipboard(item, "name"); ImGui::EndPopup(); } @@ -411,13 +712,40 @@ void section(const char* label, const std::vector& items, const cha float body_height, float maximum_height, bool resizable) { const bool open = sidebar_section_header(label, static_cast(items.size()), add_tooltip, add_notice); if (open && body_height >= ui(1.0f)) { + if (g_reset_repository_view) ImGui::SetNextWindowScroll({0.0f, 0.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_TB_CHECK : ICON_TB_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 { @@ -447,35 +775,67 @@ void add_branch_node(BranchNode& root, const std::string& branch) { void branch_context(const std::string& branch, bool remote) { if (!ImGui::BeginPopupContextItem()) return; - if (!remote && ImGui::MenuItem(ICON_FA_CODE_BRANCH " Checkout")) - g_git_manager->checkoutBranch(repo(), branch, g_notice); - if (remote && ImGui::MenuItem(ICON_FA_DOWNLOAD " Fetch")) { + if (!remote && ImGui::MenuItem(ICON_TB_CODE_BRANCH " Checkout")) { + request_branch_checkout(branch); + clear_sidebar_filter(); + } + if (!remote && ImGui::MenuItem(ICON_TB_UPLOAD " Push branch")) { + g_git_manager->pushBranch(repo(), branch, g_notice); + clear_sidebar_filter(); + } + if (remote && ImGui::MenuItem(ICON_TB_DOWNLOAD " Fetch")) { const size_t slash = branch.find('/'); 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_TB_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_TB_ARROW_UP; + if (found->second.behind > 0) { + if (!text.empty()) text += " "; + text += std::to_string(found->second.behind) + ICON_TB_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(82, 88, 98, 255) + : IM_COL32(112, 118, 128, 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 char* group_icon = depth == 0 ? root_group_icon : ICON_TB_FOLDER; + 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, + !remote && node.full_name == repo().branch); 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, + !remote && node.full_name == repo().branch); branch_context(node.full_name, remote); } } @@ -486,15 +846,16 @@ void branch_section(const char* label, const std::vector& branches, size_t section_index, float body_height, float maximum_height, bool resizable) { const bool open = sidebar_section_header(label, static_cast(branches.size()), add_tooltip, add_notice); if (open && body_height >= ui(1.0f)) { + if (g_reset_repository_view) ImGui::SetNextWindowScroll({0.0f, 0.0f}); ImGui::BeginChild((std::string(label) + "##body").c_str(), {-1, body_height}); BranchNode root; for (const auto& branch : 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 { @@ -503,26 +864,110 @@ void branch_section(const char* label, const std::vector& branches, } void draw_sidebar(float width) { - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(39 / 255.0f, 42 / 255.0f, 49 / 255.0f, 1.0f)); - ImGui::BeginChild("sidebar", {width, -ui(28.0f)}, ImGuiChildFlags_None, + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(33 / 255.0f, 36 / 255.0f, 43 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(10.0f), ui(8.0f)}); + if (g_reset_repository_view) ImGui::SetNextWindowScroll({0.0f, 0.0f}); + ImGui::BeginChild("sidebar", {width, -ui(28.0f)}, ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + const auto collapse_button = [](const char* icon, const char* tooltip) { + const ImVec2 size{ui(22.0f), ui(22.0f)}; + ImGui::InvisibleButton("##sidebar_collapse", size); + const bool clicked = ImGui::IsItemClicked(); + const ImVec2 minimum = ImGui::GetItemRectMin(); + const float icon_font_size = ImGui::GetFontSize() * 0.90f; + const ImVec2 icon_size = g_regular_font->CalcTextSizeA( + icon_font_size, std::numeric_limits::max(), 0.0f, icon); + ImGui::GetWindowDrawList()->AddText(g_regular_font, icon_font_size, + {minimum.x + (size.x - icon_size.x) * 0.5f, + minimum.y + (size.y - icon_size.y) * 0.5f}, + ImGui::IsItemHovered() ? IM_COL32(224, 229, 236, 255) : IM_COL32(158, 165, 176, 255), icon); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("%s", tooltip); + return clicked; + }; + if (g_sidebar_collapsed) { + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ui(22.0f)) * 0.5f); + if (collapse_button(ICON_TB_CIRCLE_CHEVRON_RIGHT, "Expand sidebar")) + g_sidebar_collapsed = false; + ImGui::Dummy({0.0f, ui(7.0f)}); + struct CompactSection { + const char* icon; + const char* label; + size_t count; + }; + const CompactSection sections[] = { + {ICON_TB_DESKTOP, "Local branches", repo().local_branches.size()}, + {ICON_TB_CLOUD, "Remote branches", repo().remote_branches.size()}, + {ICON_TB_TREE, "Worktrees", repo().worktrees.size()}, + {ICON_TB_LAYERS_LINKED, "Submodules", repo().submodules.size()}, + }; + for (size_t index = 0; index < std::size(sections); ++index) { + ImGui::PushID(static_cast(index)); + const ImVec2 row_size{-1.0f, ui(42.0f)}; + ImGui::InvisibleButton("##compact_section", row_size); + const ImVec2 minimum = ImGui::GetItemRectMin(); + const ImVec2 maximum = ImGui::GetItemRectMax(); + const float icon_font_size = ui(19.0f); + const ImVec2 icon_size = g_regular_font->CalcTextSizeA(icon_font_size, + std::numeric_limits::max(), 0.0f, sections[index].icon); + if (ImGui::IsItemHovered()) + ImGui::GetWindowDrawList()->AddRectFilled(minimum, maximum, IM_COL32(51, 55, 63, 150), ui(4.0f)); + ImGui::GetWindowDrawList()->AddText(g_regular_font, icon_font_size, + {minimum.x + (maximum.x - minimum.x - icon_size.x) * 0.5f, minimum.y + ui(1.0f)}, + IM_COL32(120, 126, 136, 255), sections[index].icon); + const std::string count = std::to_string(sections[index].count); + const ImVec2 count_size = ImGui::CalcTextSize(count.c_str()); + ImGui::GetWindowDrawList()->AddText( + {minimum.x + (maximum.x - minimum.x - count_size.x) * 0.5f, minimum.y + ui(24.0f)}, + IM_COL32(82, 151, 245, 255), count.c_str()); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("%s: %zu", sections[index].label, sections[index].count); + if (ImGui::IsItemClicked()) g_sidebar_collapsed = false; + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + return; + } const int viewing_count = static_cast(repo().local_branches.size() + repo().remote_branches.size() + repo().worktrees.size() + repo().submodules.size()); - ImGui::TextDisabled(ICON_FA_EYE " VIEWING %d", viewing_count); + if (collapse_button(ICON_TB_CIRCLE_CHEVRON_LEFT, "Collapse sidebar")) + g_sidebar_collapsed = true; + ImGui::SameLine(0, ui(5.0f)); + ImGui::TextDisabled(ICON_TB_EYE " VIEWING %d", viewing_count); + ImGui::Dummy({0.0f, ui(3.0f)}); ImGui::SetNextItemWidth(-1); - ImGui::InputTextWithHint("##filter", ICON_FA_MAGNIFYING_GLASS " Search sidebar...", + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(4.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(24, 27, 33, 255)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(27, 30, 36, 255)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(29, 32, 39, 255)); + ImGui::PushStyleColor(ImGuiCol_Border, IM_COL32(62, 67, 77, 255)); + ImGui::InputTextWithHint("##filter", "Filter (Ctrl + Alt + f)", g_filter.data(), g_filter.size()); - ImGui::Spacing(); + const ImVec2 filter_min = ImGui::GetItemRectMin(); + const ImVec2 filter_max = ImGui::GetItemRectMax(); + ImDrawList* filter_draw = ImGui::GetWindowDrawList(); + const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_MAGNIFYING_GLASS); + const ImVec2 icon_position( + filter_max.x - icon_size.x - ui(8.0f), + filter_min.y + ((filter_max.y - filter_min.y) - icon_size.y) * 0.5f); + filter_draw->AddText(icon_position, IM_COL32(136, 143, 154, 255), ICON_TB_MAGNIFYING_GLASS); + ImGui::PopStyleColor(4); + ImGui::PopStyleVar(2); + ImGui::Dummy({0.0f, ui(5.0f)}); + ImGui::Separator(); + ImGui::Dummy({0.0f, ui(3.0f)}); ImGui::BeginChild("sidebar_sections", {-1, -1}, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); const ImVec2 section_spacing = ImGui::GetStyle().ItemSpacing; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {section_spacing.x, 0.0f}); constexpr const char* section_ids[] = { - ICON_FA_HOUSE " LOCAL", - ICON_FA_CLOUD " REMOTE", - ICON_FA_DIAGRAM_PROJECT " WORKTREES", - ICON_FA_CUBES " SUBMODULES", + ICON_TB_DESKTOP " LOCAL", + ICON_TB_CLOUD " REMOTE", + ICON_TB_TREE " WORKTREES", + ICON_TB_LAYERS_LINKED " SUBMODULES", }; std::array section_open{}; std::vector open_indices; @@ -561,16 +1006,16 @@ void draw_sidebar(float width) { } } - branch_section(ICON_FA_HOUSE " LOCAL", repo().local_branches, ICON_FA_CODE_BRANCH, ICON_FA_FOLDER, + branch_section(ICON_TB_DESKTOP " LOCAL", repo().local_branches, ICON_TB_CODE_BRANCH, ICON_TB_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, + branch_section(ICON_TB_CLOUD " REMOTE", repo().remote_branches, ICON_TB_CODE_BRANCH, ICON_TB_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_TB_TREE " WORKTREES", repo().worktrees, ICON_TB_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, + section(ICON_TB_LAYERS_LINKED " SUBMODULES", repo().submodules, ICON_TB_LAYERS_LINKED, "Add submodule", "Add submodule", SidebarItemKind::submodule, 3, section_heights[3], maximum_heights[3], false); ImGui::PopStyleVar(); @@ -597,35 +1042,93 @@ 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); + if (badge.kind == RefKind::local) { + width += outline_icon_width(ICON_TB_DEVICE_LAPTOP) + ui(6.0f); + if (badge.current) width += outline_icon_width(ICON_TB_CHECK) + ui(5.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) { +size_t primary_ref_index(const CommitInfo& commit) { + const auto current = std::find_if(commit.refs.begin(), commit.refs.end(), [](const RefBadge& badge) { + return badge.kind == RefKind::local && badge.current; + }); + if (current != commit.refs.end()) + return static_cast(std::distance(commit.refs.begin(), current)); + const auto local = std::find_if(commit.refs.begin(), commit.refs.end(), [](const RefBadge& badge) { + return badge.kind == RefKind::local; + }); + return local == commit.refs.end() ? 0 : static_cast(std::distance(commit.refs.begin(), local)); +} + +float overflow_ref_badge_width(size_t count) { + const std::string label = "+" + std::to_string(count); + return ImGui::CalcTextSize(label.c_str()).x + ui(12.0f); +} + +bool draw_overflow_ref_badge(size_t count, int widget_index, int lane, bool row_hovered, + bool force_hover = false, ImDrawList* target_draw = nullptr, + ImDrawFlags corners = ImDrawFlags_RoundCornersAll, int background_alpha = -1) { + const std::string label = "+" + std::to_string(count); + const ImVec2 chip_size{overflow_ref_badge_width(count), ui(20.0f)}; + ImGui::PushID(widget_index); + ImGui::InvisibleButton("##more_refs", chip_size); + const bool hovered = ImGui::IsItemHovered() || force_hover; + const ImVec2 minimum = ImGui::GetItemRectMin(); + const ImVec2 maximum = ImGui::GetItemRectMax(); + ImDrawList* draw = target_draw ? target_draw : ImGui::GetWindowDrawList(); + const int alpha = background_alpha >= 0 ? background_alpha : hovered ? 155 : row_hovered ? 110 : 95; + draw->AddRectFilled(minimum, maximum, + GraphRenderer::laneColor(lane, alpha), ui(3.0f), corners); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + draw->AddText({minimum.x + (chip_size.x - text_size.x) * 0.5f, + minimum.y + (chip_size.y - text_size.y) * 0.5f}, IM_COL32(197, 203, 212, 255), label.c_str()); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::PopID(); + return hovered; +} + +void draw_ref_badge(const RefBadge& badge, int widget_index, int commit_index, int lane, + bool row_hovered = false, bool force_hover = false, ImDrawList* target_draw = nullptr, + ImDrawFlags corners = ImDrawFlags_RoundCornersAll, int background_alpha = -1, + bool interactive = true, const ImVec2* explicit_position = nullptr) { 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); - ImGui::InvisibleButton("##ref_badge", chip_size); - 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)); + const ImVec2 chip_size{ref_badge_width(badge), ui(20.0f)}; + ImGui::PushID(widget_index); + if (interactive) ImGui::InvisibleButton("##ref_badge", chip_size); + const bool item_hovered = (interactive && ImGui::IsItemHovered()) || force_hover; + const bool switchable_branch = badge.kind == RefKind::local && !badge.current; + if (interactive && ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + repo().selected_commit = commit_index; + clear_sidebar_filter(); + } + if (interactive && switchable_branch && ImGui::IsItemHovered() && + ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + request_branch_checkout(badge.name); + clear_sidebar_filter(); + } + const ImVec2 minimum = interactive ? ImGui::GetItemRectMin() : + (explicit_position ? *explicit_position : ImGui::GetCursorScreenPos()); + const ImVec2 maximum = interactive ? ImGui::GetItemRectMax() : + ImVec2{minimum.x + chip_size.x, minimum.y + chip_size.y}; + ImDrawList* draw = target_draw ? target_draw : ImGui::GetWindowDrawList(); + ImU32 background = GraphRenderer::laneColor(lane, background_alpha >= 0 + ? background_alpha : badge.current ? 205 : row_hovered ? 110 : 95); + if (item_hovered && background_alpha < 0) + background = GraphRenderer::laneColor(lane, badge.current ? 235 : 155); + const float rounding = ui(3.0f); + draw->AddRectFilled(minimum, maximum, background, rounding, corners); - float x = minimum.x + ui(6.0f); + float x = minimum.x + ui(5.0f); const float text_y = minimum.y + (chip_size.y - ImGui::GetFontSize()) * 0.5f; const float icon_y = minimum.y + (chip_size.y - g_outline_icon_size) * 0.5f; - const ImU32 color = badge.current ? IM_COL32(248, 250, 252, 255) : IM_COL32(224, 229, 234, 255); + const ImU32 color = badge.current ? IM_COL32(248, 250, 252, 255) : IM_COL32(197, 203, 212, 255); const auto draw_icon = [&](const char* icon) { if (!g_outline_icon_font) return; draw->AddText(g_outline_icon_font, g_outline_icon_size, {x, icon_y}, color, icon); @@ -633,41 +1136,181 @@ void draw_ref_badge(const RefBadge& badge, int index, int lane) { }; 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); + x += ImGui::CalcTextSize(display_name.c_str()).x + ui(5.0f); if (badge.kind == RefKind::local) draw_icon(ICON_TB_DEVICE_LAPTOP); if (show_cloud) draw_icon(ICON_TB_CLOUD); if (show_tag) draw_icon(ICON_TB_TAG); - 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 (interactive && ImGui::BeginPopupContextItem()) { + if (switchable_branch && ImGui::MenuItem(ICON_TB_CODE_BRANCH " Checkout")) { + request_branch_checkout(badge.name); + clear_sidebar_filter(); + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem(ICON_TB_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 (interactive && 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 auto commit_visible = [](const CommitInfo& commit) { - if (!g_filter[0]) return true; - if (commit.summary.find(g_filter.data()) != std::string::npos) return true; - return std::any_of(commit.refs.begin(), commit.refs.end(), [](const RefBadge& ref) { - return ref.name.find(g_filter.data()) != std::string::npos; - }); + const ScopedUiScale table_scale(1.0f); + const bool find_shortcut = ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false); + if (find_shortcut) { + if (g_commit_search_repository != &repo()) { + g_commit_search.fill('\0'); + g_previous_commit_search.clear(); + g_commit_search_match = -1; + } + g_commit_search_repository = &repo(); + g_commit_search_open = true; + g_focus_commit_search = true; + } + if (g_commit_search_open && g_commit_search_repository != &repo()) { + g_commit_search_open = false; + g_commit_search.fill('\0'); + g_previous_commit_search.clear(); + g_commit_search_match = -1; + } + + 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&) { + return true; }; + const auto search_matches = [](const CommitInfo& commit, const char* query) { + if (!query || !query[0]) return false; + const auto contains = [query](const std::string& text) { + return std::search(text.begin(), text.end(), query, query + std::strlen(query), + [](unsigned char left, unsigned char right) { + return std::tolower(left) == std::tolower(right); + }) != text.end(); + }; + return contains(commit.summary) || contains(commit.description); + }; + + std::vector commit_search_matches; + bool search_submitted = false; + int search_direction = 0; + if (g_commit_search_open) { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.16f, 0.28f, 0.50f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.13f, 0.23f, 0.42f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1, 1, 1, 0.10f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, ui(3.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(3.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ui(5.0f), ui(4.0f)}); + ImGui::BeginChild("commit_search_bar", {-1, ui(34.0f)}, ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::SetCursorPos({ui(9.0f), ui(5.0f)}); + ImGui::TextUnformatted(ICON_TB_MAGNIFYING_GLASS); + ImGui::SameLine(); + const float search_controls_width = ui(170.0f); + ImGui::SetNextItemWidth(std::max(ui(90.0f), ImGui::GetContentRegionAvail().x - search_controls_width)); + if (g_focus_commit_search) { + ImGui::SetKeyboardFocusHere(); + g_focus_commit_search = false; + } + search_submitted = ImGui::InputTextWithHint("##commit_search", "find commit", + g_commit_search.data(), g_commit_search.size(), ImGuiInputTextFlags_EnterReturnsTrue); + const bool search_input_active = ImGui::IsItemActive(); + + for (int index = 0; index < static_cast(repo().commits.size()); ++index) { + if (commit_visible(repo().commits[static_cast(index)]) && + search_matches(repo().commits[static_cast(index)], g_commit_search.data())) + commit_search_matches.push_back(index); + } + ImGui::SameLine(); + ImGui::Text("%d results", static_cast(commit_search_matches.size())); + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_ARROW_UP "##previous_commit_match")) search_direction = -1; + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_ARROW_DOWN "##next_commit_match")) search_direction = 1; + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_XMARK "##close_commit_search") || + (search_input_active && ImGui::IsKeyPressed(ImGuiKey_Escape))) { + g_commit_search_open = false; + g_commit_search.fill('\0'); + g_previous_commit_search.clear(); + g_commit_search_match = -1; + } + ImGui::EndChild(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(4); + } + + const std::string current_search = g_commit_search.data(); + const bool search_changed = current_search != g_previous_commit_search; + if (search_changed) { + g_previous_commit_search = current_search; + g_commit_search_match = commit_search_matches.empty() ? -1 : 0; + if (!commit_search_matches.empty()) { + repo().selected_commit = commit_search_matches.front(); + repo().scroll_to_commit = commit_search_matches.front(); + } + } else if (search_submitted && !commit_search_matches.empty()) { + search_direction = 1; + } + if (search_direction != 0 && !commit_search_matches.empty()) { + const int count = static_cast(commit_search_matches.size()); + g_commit_search_match = (std::max(0, g_commit_search_match) + search_direction + count) % count; + } + if ((search_changed || search_direction != 0) && g_commit_search_match >= 0 && + g_commit_search_match < static_cast(commit_search_matches.size())) { + const int commit_index = commit_search_matches[static_cast(g_commit_search_match)]; + repo().selected_commit = commit_index; + repo().scroll_to_commit = commit_index; + } + float widest_reference_row = 0.0f; for (const CommitInfo& commit : repo().commits) { - float width = 0.0f; - for (const RefBadge& badge : commit.refs) - width += ref_badge_width(badge) + (width > 0.0f ? ui(4.0f) : 0.0f); + if (commit.refs.empty()) continue; + const size_t primary = primary_ref_index(commit); + float width = ref_badge_width(commit.refs[primary]); + if (commit.refs.size() > 1) + width += overflow_ref_badge_width(commit.refs.size() - 1); + for (const RefBadge& badge : commit.refs) width = std::max(width, ref_badge_width(badge)); 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; @@ -689,25 +1332,16 @@ void draw_commit_table() { for (size_t index = 0; index < repo().commits.size(); ++index) { const CommitInfo& commit = repo().commits[index]; if (!commit_visible(commit)) continue; - int lines = commit.refs.empty() ? 0 : 1; - float line_width = 0.0f; - for (const RefBadge& badge : commit.refs) { - const float badge_width = ref_badge_width(badge); - const float spacing = line_width > 0.0f ? ui(4.0f) : 0.0f; - if (line_width > 0.0f && line_width + spacing + badge_width > chip_line_width) { - ++lines; - line_width = badge_width; - } else { - line_width += spacing + badge_width; - } - } + const int lines = commit.refs.empty() ? 0 : 1; row_heights[index] = std::max(ui(24.0f), lines * ui(23.0f) + ui(1.0f)); if (g_inline_branch_repository == &repo() && g_inline_branch_commit == static_cast(index)) 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_NoBordersInBody | ImGuiTableFlags_NoBordersInBodyUntilResize | + 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 +1349,115 @@ 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::PushFont(g_regular_font, ImGui::GetStyle().FontSizeBase * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(134, 140, 151, 255)); ImGui::TableHeadersRow(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + const float table_body_clip_top = ImGui::GetItemRectMax().y; + const float table_body_clip_bottom = ImGui::GetWindowPos().y + ImGui::GetWindowHeight(); + const auto graph_row_interaction = [&](const char* id, bool selected, float row_height, + bool& hovered) { + 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 message_left = row_left + reference_width + graph_width; + const float row_right = message_left + message_width + date_width + + ImGui::GetStyle().CellPadding.x * 2.0f; + const ImVec2 mouse = ImGui::GetIO().MousePos; + hovered = ImGui::IsWindowHovered() && mouse.x >= row_left && mouse.x < row_right && + mouse.y >= row_top && mouse.y < row_top + row_height; + if (selected || hovered) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + draw->PushClipRect({message_left, table_body_clip_top}, + {row_right, table_body_clip_bottom}, false); + draw->AddRectFilled({message_left, row_top}, {row_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)) + bool pending_hovered = false; + if (graph_row_interaction("##working_tree", repo().selected_commit == -1, + ui(24.0f), pending_hovered)) 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->PushClipRect({position.x, table_body_clip_top}, + {position.x + graph_width, table_body_clip_bottom}, false); + 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_TB_PEN " ") + std::to_string(modified_count); + const std::string added_text = std::string(ICON_TB_PLUS " ") + std::to_string(added_count); + const std::string deleted_text = std::string(ICON_TB_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)); + const ScopedUiScale counter_scale(0.86f); + ImVec4 color = change_color(kind); + color.x *= 0.84f; + color.y *= 0.84f; + color.z *= 0.84f; + ImGui::TextColored(color, "%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,34 +1466,83 @@ 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; + bool row_hovered = false; + if (graph_row_interaction(row_id.c_str(), repo().selected_commit == i, row_height, row_hovered)) { + repo().selected_commit = i; + clear_sidebar_filter(); + } + const std::string commit_hash = oid_string(commit.oid); 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); + repo().selected_commit = i; + if (ImGui::MenuItem(ICON_TB_ARROW_DOWN " Checkout this commit")) { + run_repository_action({"checkout", commit_hash}, "Checked out commit " + commit.short_id, commit_hash); + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem(ICON_TB_TREE " Create worktree from this commit")) { + g_git_path.fill('\0'); + g_git_value.fill('\0'); + std::snprintf(g_git_value.data(), g_git_value.size(), "%s", commit_hash.c_str()); + g_worktree_add_popup = true; + ImGui::CloseCurrentPopup(); } - if (ImGui::MenuItem(ICON_FA_COPY " Copy commit message")) - ImGui::SetClipboardText(commit.summary.c_str()); ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_TAG " Create tag here")) { - char target[GIT_OID_SHA1_HEXSIZE + 1]{}; - git_oid_tostr(target, sizeof(target), &commit.oid); - g_git_target = target; + if (ImGui::MenuItem(ICON_TB_COPY " Copy commit hash")) { + copy_to_clipboard(commit_hash, "commit hash"); + } + if (ImGui::MenuItem(ICON_TB_COPY " Copy commit message")) + copy_to_clipboard(commit.summary, "commit message"); + ImGui::Separator(); + if (ImGui::MenuItem(ICON_TB_TAG " Create tag here")) { + g_git_target = commit_hash; g_tag_create_popup = true; } - if (ImGui::MenuItem(ICON_FA_CODE_BRANCH " Create branch here")) { - begin_inline_branch(i); + if (ImGui::MenuItem(ICON_TB_CODE_BRANCH " Create branch here")) { + g_git_target = commit_hash; + g_branch_create_popup = true; + } + ImGui::Separator(); + if (ImGui::MenuItem(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Cherry-pick commit")) { + run_repository_action({"cherry-pick", commit_hash}, "Cherry-picked " + commit.short_id); + ImGui::CloseCurrentPopup(); + } + if (ImGui::BeginMenu(ICON_TB_ROTATE_LEFT " Reset current branch to this commit")) { + if (ImGui::MenuItem("Soft reset")) { + run_repository_action({"reset", "--soft", commit_hash}, + "Soft reset to " + commit.short_id, commit_hash); + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem("Mixed reset")) { + run_repository_action({"reset", "--mixed", commit_hash}, + "Reset to " + commit.short_id, commit_hash); + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem("Hard reset")) { + run_repository_action({"reset", "--hard", commit_hash}, + "Hard reset to " + commit.short_id, commit_hash); + ImGui::CloseCurrentPopup(); + } + ImGui::EndMenu(); + } + if (ImGui::MenuItem(ICON_TB_ROTATE_RIGHT " Revert commit")) { + run_repository_action({"revert", "--no-edit", commit_hash}, "Reverted " + commit.short_id); + ImGui::CloseCurrentPopup(); + } + if (ImGui::MenuItem(ICON_TB_FILE " Copy patch for commit")) { + std::string patch; + if (g_git_manager->captureGit(repo(), {"format-patch", "-1", commit_hash, "--stdout"}, patch, g_notice)) + copy_to_clipboard(patch, "commit patch"); } ImGui::EndPopup(); } float chip_x = reference_origin.x + ui(3.0f); float chip_y = reference_origin.y + ui(0.5f); - const float chip_right = reference_origin.x + chip_line_width; const bool editing_branch = g_inline_branch_repository == &repo() && g_inline_branch_commit == i; if (editing_branch) { ImGui::SetCursorScreenPos({reference_origin.x + ui(3.0f), reference_origin.y + ui(1.0f)}); @@ -800,34 +1560,105 @@ 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]); - if (chip_x > reference_origin.x + ui(3.0f) && chip_x + badge_width > chip_right) { - chip_x = reference_origin.x + ui(3.0f); - chip_y += ui(23.0f); + if (!editing_branch && !commit.refs.empty()) { + const size_t primary = primary_ref_index(commit); + const bool refs_expanded = g_expanded_refs_repository == &repo() && g_expanded_refs_commit == i; + const auto draw_badge = [&](size_t ref_index, ImDrawList* target = nullptr, + ImDrawFlags corners = ImDrawFlags_RoundCornersAll, bool force_hover = false, + int background_alpha = -1, bool interactive = true) { + const ImVec2 badge_position{chip_x, chip_y}; + if (interactive) ImGui::SetCursorScreenPos(badge_position); + draw_ref_badge(commit.refs[ref_index], i * 1000 + static_cast(ref_index), + i, commit.graph_color, row_hovered, force_hover, target, corners, + background_alpha, interactive, interactive ? nullptr : &badge_position); + }; + if (refs_expanded) { + float stack_width = ref_badge_width(commit.refs[primary]) + + overflow_ref_badge_width(commit.refs.size() - 1); + for (const RefBadge& badge : commit.refs) + stack_width = std::max(stack_width, ref_badge_width(badge)); + const float stack_height = ui(20.0f) + ui(23.0f) * (commit.refs.size() - 1); + const ImVec2 mouse = ImGui::GetIO().MousePos; + const bool stack_hovered = mouse.x >= reference_origin.x + ui(3.0f) && + mouse.x < reference_origin.x + ui(3.0f) + stack_width && + mouse.y >= reference_origin.y + ui(0.5f) && + mouse.y < reference_origin.y + ui(0.5f) + stack_height; + ImDrawList* overlay = ImGui::GetForegroundDrawList(); + overlay->PushClipRect({reference_origin.x, table_body_clip_top}, + {reference_origin.x + reference_width, table_body_clip_bottom}, false); + draw_badge(primary, overlay, ImDrawFlags_RoundCornersAll, stack_hovered, -1, false); + for (size_t ref_index = 0; ref_index < commit.refs.size(); ++ref_index) { + if (ref_index == primary) continue; + chip_y += ui(23.0f); + chip_x = reference_origin.x + ui(3.0f); + draw_badge(ref_index, overlay, ImDrawFlags_RoundCornersAll, + stack_hovered, -1, false); + } + overlay->PopClipRect(); + if (!stack_hovered) { + g_expanded_refs_repository = nullptr; + g_expanded_refs_commit = -1; + } + } else if (commit.refs.size() == 1) { + draw_badge(primary); + } else { + const float primary_width = ref_badge_width(commit.refs[primary]); + const float more_width = overflow_ref_badge_width(commit.refs.size() - 1); + const float chip_gap = ui(4.0f); + const ImVec2 mouse = ImGui::GetIO().MousePos; + const bool group_hovered = mouse.x >= chip_x && mouse.x < chip_x + primary_width + chip_gap + more_width && + mouse.y >= chip_y && mouse.y < chip_y + ui(20.0f); + const int group_alpha = group_hovered ? + (commit.refs[primary].current ? 235 : 155) : + (commit.refs[primary].current ? 205 : row_hovered ? 110 : 95); + draw_badge(primary, nullptr, ImDrawFlags_RoundCornersAll, group_hovered, group_alpha); + chip_x += primary_width + chip_gap; + ImGui::SetCursorScreenPos({chip_x, chip_y}); + if (draw_overflow_ref_badge(commit.refs.size() - 1, i * 1000 + 999, + commit.graph_color, row_hovered, group_hovered, nullptr, + ImDrawFlags_RoundCornersAll, group_alpha) || group_hovered) { + g_expanded_refs_repository = &repo(); + g_expanded_refs_commit = i; + } } - ImGui::SetCursorScreenPos({chip_x, chip_y}); - draw_ref_badge(commit.refs[ref_index], i * 1000 + ref_index, commit.lane); - 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()); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + repo().selected_commit = i; + clear_sidebar_filter(); + } + ImDrawList* message_draw = ImGui::GetWindowDrawList(); + message_draw->PushClipRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), true); + const bool searching_commits = g_commit_search_open && g_commit_search[0] != '\0'; + const bool focused_search_match = searching_commits && g_commit_search_match >= 0 && + g_commit_search_match < static_cast(commit_search_matches.size()) && + commit_search_matches[static_cast(g_commit_search_match)] == i; + const bool other_search_match = searching_commits && search_matches(commit, g_commit_search.data()); + const ImU32 summary_color = !searching_commits || focused_search_match + ? ImGui::GetColorU32(ImGuiCol_Text) + : other_search_match ? IM_COL32(170, 175, 184, 180) : IM_COL32(112, 117, 126, 90); + const ImU32 description_color = !searching_commits || focused_search_match + ? IM_COL32(143, 149, 160, 255) + : other_search_match ? IM_COL32(130, 136, 147, 155) : IM_COL32(100, 105, 114, 70); + message_draw->AddText(message_position, summary_color, 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}, + description_color, commit.description.c_str()); + } + message_draw->PopClipRect(); ImGui::PopID(); ImGui::TableSetColumnIndex(3); + ImGui::PushFont(nullptr, ImGui::GetStyle().FontSizeBase * 0.75f); ImGui::TextDisabled("%s", commit.date.c_str()); + ImGui::PopFont(); } const bool load_more_history = !repo().history_exhausted && repo().commit_walk && ImGui::GetScrollMaxY() - ImGui::GetScrollY() < ImGui::GetWindowHeight() * 1.5f; @@ -843,80 +1674,225 @@ void draw_commit_table() { } const char* change_icon(FileChangeKind kind) { - if (kind == FileChangeKind::added) return ICON_FA_CIRCLE_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::added) return ICON_TB_PLUS; + if (kind == FileChangeKind::deleted) return ICON_TB_MINUS; + if (kind == FileChangeKind::renamed) return ICON_TB_ARROW_RIGHT_ARROW_LEFT; + if (kind == FileChangeKind::modified) return ICON_TB_PEN; + return ICON_TB_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}; +} + +struct FileTypeBadge { + const char* label = nullptr; + ImU32 background = IM_COL32(66, 72, 82, 255); + ImU32 text = IM_COL32(226, 229, 233, 255); +}; + +FileTypeBadge file_type_badge(std::string_view path) { + const auto extension = std::filesystem::path(path).extension().string(); + std::string lowered = extension; + std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + if (lowered == ".c") return {"C", IM_COL32(58, 91, 168, 255)}; + if (lowered == ".h") return {"H", IM_COL32(72, 93, 138, 255)}; + if (lowered == ".cc" || lowered == ".cpp" || lowered == ".cxx" || + lowered == ".hh" || lowered == ".hpp" || lowered == ".hxx") + return {"C++", IM_COL32(53, 108, 186, 255)}; + if (lowered == ".cs") return {"C#", IM_COL32(115, 82, 180, 255)}; + if (lowered == ".rs") return {"RS", IM_COL32(170, 96, 55, 255)}; + if (lowered == ".py" || lowered == ".pyw") return {"PY", IM_COL32(76, 122, 190, 255)}; + if (lowered == ".js" || lowered == ".jsx" || lowered == ".mjs" || lowered == ".cjs") + return {"JS", IM_COL32(192, 164, 50, 255), IM_COL32(30, 30, 34, 255)}; + if (lowered == ".ts" || lowered == ".tsx") return {"TS", IM_COL32(45, 119, 204, 255)}; + if (lowered == ".lua") return {"LUA", IM_COL32(80, 97, 184, 255)}; + if (lowered == ".sh" || lowered == ".bash" || lowered == ".zsh") return {"SH", IM_COL32(74, 124, 88, 255)}; + if (lowered == ".bat" || lowered == ".cmd") return {"BAT", IM_COL32(102, 102, 112, 255)}; + if (lowered == ".ps1") return {"PS", IM_COL32(57, 108, 173, 255)}; + if (lowered == ".json") return {"{}", IM_COL32(128, 110, 62, 255)}; + if (lowered == ".xml" || lowered == ".html" || lowered == ".htm") return {"<>", IM_COL32(160, 88, 62, 255)}; + if (lowered == ".css" || lowered == ".scss") return {"CSS", IM_COL32(88, 112, 201, 255)}; + if (lowered == ".md") return {"MD", IM_COL32(84, 97, 112, 255)}; + if (lowered == ".yml" || lowered == ".yaml") return {"YML", IM_COL32(132, 74, 74, 255)}; + if (lowered == ".toml") return {"TOML", IM_COL32(126, 88, 70, 255)}; + return {}; +} + +float file_type_badge_width(const FileTypeBadge& badge) { + return badge.label ? ImGui::CalcTextSize(badge.label).x + ui(10.0f) : 0.0f; +} + +void draw_file_type_badge(ImDrawList* draw, const ImVec2& minimum, float y, const FileTypeBadge& badge) { + if (!badge.label) return; + const float width = file_type_badge_width(badge); + const float height = ui(16.0f); + const ImVec2 top_left{minimum.x + ui(19.0f), minimum.y + (ui(25.0f) - height) * 0.5f}; + const ImVec2 bottom_right{top_left.x + width, top_left.y + height}; + draw->AddRectFilled(top_left, bottom_right, badge.background, ui(3.0f)); + const ImVec2 text_size = ImGui::CalcTextSize(badge.label); + draw->AddText({top_left.x + (width - text_size.x) * 0.5f, y}, badge.text, badge.label); +} + +float file_action_button_width(const char* label) { + return ImGui::CalcTextSize(label).x + ui(16.0f); +} + +bool file_action_button(const char* label, bool positive) { + if (positive) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.23f, 0.17f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.16f, 0.29f, 0.21f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.18f, 0.33f, 0.24f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.20f, 0.34f, 0.25f, 1.0f)); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.24f, 0.11f, 0.12f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.14f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.35f, 0.16f, 0.17f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.34f, 0.18f, 0.19f, 1.0f)); + } + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, ui(1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ui(7.0f), ui(2.0f)}); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(3.0f)); + const bool clicked = ImGui::Button(label, {file_action_button_width(label), ui(21.0f)}); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(4); + return clicked; +} + +float file_section_toggle_width(const std::string& label) { + return ImGui::CalcTextSize(label.c_str()).x + ui(12.0f); +} + +bool file_section_toggle(const char* id, const std::string& label) { + const float width = file_section_toggle_width(label); + return ImGui::InvisibleButton(id, {width, ui(24.0f)}); } 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::SetNextItemAllowOverlap(); 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); + const bool row_clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); + const bool row_hovered = ImGui::IsItemHovered(); const ImVec2 minimum = ImGui::GetItemRectMin(); const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); - if (ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(48, 52, 60, 255)); + if (row_hovered) draw->AddRectFilled(minimum, maximum, IM_COL32(48, 52, 60, 255)); const float y = minimum.y + (maximum.y - minimum.y - ImGui::GetFontSize()) * 0.5f; + const FileTypeBadge badge = file_type_badge(git_path); + const float reserved_action_width = working_file + ? std::max(file_action_button_width("Stage File"), file_action_button_width("Unstage File")) + ui(8.0f) + : 0.0f; + const float text_right = maximum.x - reserved_action_width; 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()); + draw_file_type_badge(draw, minimum, y, badge); + const float text_x = minimum.x + ui(20.0f) + (badge.label ? file_type_badge_width(badge) + ui(6.0f) : 0.0f); + draw->PushClipRect({text_x, minimum.y}, {std::max(text_x, text_right), maximum.y}, true); + draw->AddText({text_x, y}, IM_COL32(205, 209, 216, 255), path.c_str()); + draw->PopClipRect(); if (ImGui::BeginPopupContextItem()) { - if (working_file && !staged && ImGui::MenuItem(ICON_FA_CIRCLE_PLUS " Stage file")) + if (working_file && !staged && ImGui::MenuItem(ICON_TB_PLUS " Stage file")) g_git_manager->stageFile(repo(), git_path, g_notice); - if (working_file && staged && ImGui::MenuItem(ICON_FA_MINUS " Unstage file")) + if (working_file && staged && ImGui::MenuItem(ICON_TB_MINUS " Unstage file")) g_git_manager->unstageFile(repo(), git_path, g_notice); - if (working_file && !staged && ImGui::MenuItem(ICON_FA_TRASH_CAN " Discard changes")) + if (working_file && !staged && ImGui::MenuItem(ICON_TB_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_TB_COPY " Copy path")) copy_to_clipboard(path, "path"); ImGui::EndPopup(); } + bool row_action_clicked = false; + if (working_file && row_hovered) { + const char* label = staged ? "Unstage File" : "Stage File"; + const float button_width = file_action_button_width(label); + ImGui::SetCursorScreenPos({maximum.x - button_width - ui(4.0f), minimum.y + ui(2.5f)}); + row_action_clicked = file_action_button(label, !staged); + } + if (row_action_clicked) { + if (staged) g_git_manager->unstageFile(repo(), git_path, g_notice); + else g_git_manager->stageFile(repo(), git_path, g_notice); + } else if (row_clicked) { + 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); + } 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); - 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_view_all && g_view_all_files) g_file_view_mode = FileViewMode::tree; + if (ImGui::SmallButton(g_file_sort_ascending ? ICON_TB_ARROW_DOWN_A_Z : ICON_TB_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 auto button_width = [](const char* label) { + return ImGui::CalcTextSize(label).x + ImGui::GetStyle().FramePadding.x * 2.0f; + }; + const char* path_label = ICON_TB_BARS " Path"; + const char* tree_label = ICON_TB_TREE " Tree"; + float toggle_width = button_width(tree_label); + if (show_path) toggle_width += button_width(path_label); + if (show_path) toggle_width += ImGui::GetStyle().ItemSpacing.x; + const float row_left = ImGui::GetCursorPosX(); + const float row_width = ImGui::GetContentRegionAvail().x; + const float toggle_x = row_left + std::max(0.0f, (row_width - toggle_width) * 0.5f); + ImGui::SameLine(std::max(ImGui::GetCursorPosX() + ui(8), toggle_x)); + 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(path_label)) 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 (ImGui::Button(tree_label)) 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 (text_height_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('/'); @@ -925,14 +1901,16 @@ 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)) { + if (ImGui::TreeNodeEx((std::string(ICON_TB_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(); } } @@ -946,12 +1924,19 @@ void draw_working_details() { ImGui::BeginChild("working_header", {-1, ui(34.0f)}, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::SetCursorPos({0.0f, ui(5.0f)}); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.27f, 0.10f, 0.11f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.38f, 0.13f, 0.14f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.88f, 0.36f, 0.36f, 1.0f)); - if (ImGui::Button(ICON_FA_TRASH_CAN "##discard_all", {ui(23.0f), ui(23.0f)})) - g_discard_all_popup = true; - ImGui::PopStyleColor(3); + const bool discard_clicked = ImGui::InvisibleButton("##discard_all", {ui(23.0f), ui(23.0f)}); + const ImVec2 discard_minimum = ImGui::GetItemRectMin(); + const ImVec2 discard_maximum = ImGui::GetItemRectMax(); + ImDrawList* header_draw = ImGui::GetWindowDrawList(); + header_draw->AddRectFilled(discard_minimum, discard_maximum, + ImGui::IsItemHovered() ? IM_COL32(97, 33, 36, 255) : IM_COL32(69, 26, 28, 255), ui(3.0f)); + const ImVec2 trash_size = ImGui::CalcTextSize(ICON_TB_TRASH_CAN); + header_draw->AddText({ + discard_minimum.x + (discard_maximum.x - discard_minimum.x - trash_size.x) * 0.5f, + discard_minimum.y + (discard_maximum.y - discard_minimum.y - trash_size.y) * 0.5f - ui(1.0f), + }, IM_COL32(224, 92, 92, 255), ICON_TB_TRASH_CAN); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("Discard all changes"); + if (discard_clicked) g_discard_all_popup = true; const std::string changes_label = std::to_string(repo().working_files.size()) + " file changes on"; const float branch_width = ImGui::CalcTextSize(repo().branch.c_str()).x + ui(10.0f); @@ -978,8 +1963,13 @@ void draw_working_details() { draw_file_toolbar(false); ImGui::EndChild(); - const float composer_height = ui(320.0f); - const float files_height = std::max(ui(140.0f), ImGui::GetContentRegionAvail().y - composer_height); + const float split_height = ImGui::GetContentRegionAvail().y; + const float minimum_files_height = ui(140.0f); + const float minimum_composer_height = ui(180.0f); + const float maximum_composer_height = std::max(minimum_composer_height, split_height - minimum_files_height); + g_working_composer_height = std::clamp(g_working_composer_height, minimum_composer_height, maximum_composer_height); + const float composer_height = g_working_composer_height; + const float files_height = std::max(minimum_files_height, split_height - composer_height); ImGui::BeginChild("working_files", {-1, files_height}, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); static bool unstaged_open = true; @@ -997,35 +1987,59 @@ void draw_working_details() { ImGui::BeginChild("unstaged_files", {-1, unstaged_height}, ImGuiChildFlags_None); const ImVec2 unstaged_header = ImGui::GetCursorScreenPos(); - ImGui::InvisibleButton("##unstaged_toggle", {-1, ui(24.0f)}); - if (ImGui::IsItemClicked()) unstaged_open = !unstaged_open; + const std::string unstaged_label = + std::string(unstaged_open ? ICON_TB_CHEVRON_DOWN " " : ICON_TB_CHEVRON_RIGHT " ") + + "Unstaged Files (" + std::to_string(unstaged) + ")"; + file_section_toggle("##unstaged_toggle", unstaged_label); + const bool unstaged_header_clicked = ImGui::IsItemClicked(); ImGui::GetWindowDrawList()->AddText( {unstaged_header.x + ui(4.0f), unstaged_header.y + ui(4.0f)}, ImGui::GetColorU32(ImGuiCol_Text), - (std::string(unstaged_open ? ICON_FA_CHEVRON_DOWN " " : ICON_FA_CHEVRON_RIGHT " ") + - "Unstaged Files (" + std::to_string(unstaged) + ")").c_str()); - ImGui::SetCursorScreenPos({ImGui::GetWindowPos().x + ImGui::GetWindowWidth() - ui(119.0f), + unstaged_label.c_str()); + const float stage_all_width = file_action_button_width("Stage All Changes"); + ImGui::SetCursorScreenPos({ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x - + stage_all_width - ui(5.0f), unstaged_header.y + ui(2.0f)}); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.27f, 0.18f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.24f, 0.72f, 0.35f, 1.0f)); - if (ImGui::SmallButton("Stage All Changes")) g_git_manager->stageAll(repo(), g_notice); - ImGui::PopStyleColor(2); + ImGui::BeginDisabled(unstaged == 0); + const bool stage_all_clicked = file_action_button("Stage All Changes", true); + ImGui::EndDisabled(); + if (stage_all_clicked) g_git_manager->stageAll(repo(), g_notice); + else if (unstaged_header_clicked) unstaged_open = !unstaged_open; ImGui::SetCursorScreenPos({unstaged_header.x, unstaged_header.y + ui(24.0f)}); ImGui::Separator(); - if (unstaged_open) draw_files(repo().working_files, false, true); + if (unstaged_open) { + ImGui::BeginChild("unstaged_files_body", {-1, -1}, ImGuiChildFlags_None); + draw_files(repo().working_files, false, true); + ImGui::EndChild(); + } ImGui::EndChild(); ImGui::BeginChild("staged_files", {-1, staged_height}, ImGuiChildFlags_None); const ImVec2 staged_header = ImGui::GetCursorScreenPos(); - ImGui::InvisibleButton("##staged_toggle", {-1, ui(24.0f)}); - if (ImGui::IsItemClicked()) staged_open = !staged_open; + const std::string staged_label = + std::string(staged_open ? ICON_TB_CHEVRON_DOWN " " : ICON_TB_CHEVRON_RIGHT " ") + + "Staged Files (" + std::to_string(staged) + ")"; + file_section_toggle("##staged_toggle", staged_label); + const bool staged_header_clicked = ImGui::IsItemClicked(); ImGui::GetWindowDrawList()->AddText( {staged_header.x + ui(4.0f), staged_header.y + ui(4.0f)}, ImGui::GetColorU32(ImGuiCol_Text), - (std::string(staged_open ? ICON_FA_CHEVRON_DOWN " " : ICON_FA_CHEVRON_RIGHT " ") + - "Staged Files (" + std::to_string(staged) + ")").c_str()); + staged_label.c_str()); + const float unstage_all_width = file_action_button_width("Unstage All Changes"); + ImGui::SetCursorScreenPos({ImGui::GetWindowPos().x + ImGui::GetContentRegionMax().x - + unstage_all_width - ui(5.0f), staged_header.y + ui(2.0f)}); + ImGui::BeginDisabled(staged == 0); + const bool unstage_all_clicked = file_action_button("Unstage All Changes", false); + ImGui::EndDisabled(); + if (unstage_all_clicked) g_git_manager->unstageAll(repo(), g_notice); + else if (staged_header_clicked) staged_open = !staged_open; + ImGui::SetCursorScreenPos({staged_header.x, staged_header.y + ui(24.0f)}); ImGui::Separator(); - if (staged_open) draw_files(repo().working_files, true, true); + if (staged_open) { + ImGui::BeginChild("staged_files_body", {-1, -1}, ImGuiChildFlags_None); + draw_files(repo().working_files, true, true); + ImGui::EndChild(); + } ImGui::EndChild(); ImGui::EndChild(); @@ -1033,28 +2047,55 @@ void draw_working_details() { ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); const float handle_x = (ImGui::GetWindowWidth() - ui(90.0f)) * 0.5f; const ImVec2 handle = ImGui::GetCursorScreenPos(); + ImGui::SetCursorPosX(handle_x); + ImGui::InvisibleButton("##working_composer_resize", {ui(90.0f), ui(10.0f)}); + const bool composer_resize_active = ImGui::IsItemActive(); + const bool composer_resize_hovered = ImGui::IsItemHovered(); + if (composer_resize_active || composer_resize_hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + if (composer_resize_active) { + g_working_composer_height = std::clamp( + g_working_composer_height + ImGui::GetIO().MouseDelta.y, + minimum_composer_height, maximum_composer_height); + } ImGui::GetWindowDrawList()->AddLine( {handle.x + handle_x, handle.y + ui(3.0f)}, {handle.x + handle_x + ui(90.0f), handle.y + ui(3.0f)}, - IM_COL32(56, 59, 65, 255), ui(3.0f)); + composer_resize_active || composer_resize_hovered + ? IM_COL32(23, 181, 204, 255) : IM_COL32(56, 59, 65, 255), + ui(3.0f)); ImGui::Dummy({0.0f, ui(10.0f)}); - ImGui::TextUnformatted(ICON_FA_CIRCLE_DOT " Commit"); - ImGui::SameLine(0, ui(14.0f)); - ImGui::TextDisabled(ICON_FA_DOWNLOAD); - ImGui::SameLine(0, ui(14.0f)); - ImGui::TextDisabled(ICON_FA_CLOUD); + ImGui::TextUnformatted(ICON_TB_CIRCLE_DOT " Commit"); ImGui::Separator(); + ImGui::Indent(ui(8.0f)); static bool amend = false; - ImGui::Checkbox("Amend previous commit", &amend); + text_height_checkbox("Amend previous commit", &amend); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(31 / 255.0f, 34 / 255.0f, 39 / 255.0f, 1.0f)); - ImGui::BeginChild("commit_message_card", {-1, ui(165.0f)}, ImGuiChildFlags_Borders); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(22 / 255.0f, 25 / 255.0f, 30 / 255.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(70 / 255.0f, 77 / 255.0f, 89 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, ui(1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, ui(4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(8.0f), ui(7.0f)}); + ImGui::BeginChild("commit_message_card", {-1, ui(165.0f)}, + ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(15 / 255.0f, 18 / 255.0f, 22 / 255.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(15 / 255.0f, 18 / 255.0f, 22 / 255.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(15 / 255.0f, 18 / 255.0f, 22 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, ui(3.0f)); const float summary_controls_width = ui(30.0f); + const int commit_summary_recommendation = 72; + const int commit_summary_length = static_cast(std::strlen(g_commit_summary.data())); + const int commit_summary_remaining = commit_summary_recommendation - commit_summary_length; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - summary_controls_width); ImGui::InputTextWithHint("##commit_summary", "Commit summary", g_commit_summary.data(), g_commit_summary.size()); ImGui::SameLine(); - ImGui::TextDisabled("72"); + const ImVec4 summary_counter_color = commit_summary_remaining >= 0 + ? ImVec4(0.60f, 0.62f, 0.66f, 1.0f) + : commit_summary_remaining > -10 + ? ImVec4(0.92f, 0.76f, 0.31f, 1.0f) + : ImVec4(0.92f, 0.42f, 0.42f, 1.0f); + ImGui::TextColored(summary_counter_color, "%d", commit_summary_remaining); ImGui::SetNextItemWidth(-1); ImGui::InputTextMultiline("##commit_description", g_commit_description.data(), g_commit_description.size(), {-1, -1}); @@ -1064,15 +2105,18 @@ void draw_working_details() { {description_minimum.x + ui(6.0f), description_minimum.y + ui(5.0f)}, IM_COL32(154, 159, 168, 255), "Description"); } + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(3); ImGui::EndChild(); - ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(2); - ImGui::TextDisabled(ICON_FA_CHEVRON_RIGHT " Commit options"); + ImGui::TextDisabled(ICON_TB_CHEVRON_RIGHT " Commit options"); ImGui::BeginDisabled(staged == 0 || g_commit_summary[0] == '\0'); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.24f, 0.18f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.24f, 0.54f, 0.32f, 1.0f)); - if (ImGui::Button(ICON_FA_CIRCLE_DOT " Stage Changes to Commit", {-1, ui(40.0f)})) { + if (ImGui::Button(ICON_TB_CIRCLE_DOT " Stage Changes to Commit", {-1, ui(40.0f)})) { if (g_git_manager->commit(repo(), g_commit_summary.data(), g_commit_description.data(), amend, g_notice)) { g_commit_summary.fill('\0'); g_commit_description.fill('\0'); @@ -1081,34 +2125,87 @@ void draw_working_details() { } ImGui::PopStyleColor(2); ImGui::EndDisabled(); + ImGui::Unindent(ui(8.0f)); ImGui::EndChild(); } 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]; + if (!repo().working_files.empty()) { + const std::string changes = std::to_string(repo().working_files.size()) + + (repo().working_files.size() == 1 + ? " file change in working directory" + : " file changes in working directory"); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(43 / 255.0f, 72 / 255.0f, 139 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(9.0f), ui(5.0f)}); + ImGui::BeginChild("working_changes_banner", {-1, ui(34.0f)}, + ImGuiChildFlags_AlwaysUseWindowPadding, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(changes.c_str()); + const char* view_label = "View Changes"; + const float button_width = ImGui::CalcTextSize(view_label).x + ImGui::GetStyle().FramePadding.x * 2.0f; + ImGui::SameLine(std::max(ImGui::GetCursorPosX() + ui(8.0f), + ImGui::GetContentRegionMax().x - button_width)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.16f, 0.24f, 0.45f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.31f, 0.58f, 1.0f)); + if (ImGui::Button(view_label)) repo().selected_commit = -1; + ImGui::PopStyleColor(2); + ImGui::EndChild(); + ImGui::PopStyleColor(); + ImGui::Dummy({0.0f, ui(5.0f)}); + } 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::PushStyleColor(ImGuiCol_ChildBg, ImVec4(22 / 255.0f, 25 / 255.0f, 30 / 255.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(70 / 255.0f, 77 / 255.0f, 89 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, ui(1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, ui(4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(8.0f), ui(7.0f)}); + ImGui::BeginChild("message_card", {-1, ui(g_commit_message_height)}, + ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding); 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::PopStyleVar(3); + ImGui::PopStyleColor(2); + 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) { - ImGui::Image(ImTextureRef(static_cast(author_texture)), {ui(40), ui(40)}); + const ImVec2 avatar_size{ui(40.0f), ui(40.0f)}; + ImGui::InvisibleButton("##author_avatar", avatar_size); + const ImVec2 avatar_minimum = ImGui::GetItemRectMin(); + ImGui::GetWindowDrawList()->AddImageRounded( + ImTextureRef(static_cast(author_texture)), + avatar_minimum, {avatar_minimum.x + avatar_size.x, avatar_minimum.y + avatar_size.y}, + {0, 0}, {1, 1}, IM_COL32_WHITE, ui(6.0f)); ImGui::SameLine(); } ImGui::BeginGroup(); @@ -1116,10 +2213,14 @@ 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()); - ImGui::SameLine(std::max(ui(180), ImGui::GetWindowWidth() - ui(95))); - ImGui::TextDisabled("parent: %.7s", parent); + const std::string parent_hash = oid_string(commit.parent_ids.front()); + const float parent_width = ImGui::CalcTextSize("parent:").x + ui(4.0f) + + ImGui::CalcTextSize(parent_hash.substr(0, 7).c_str()).x; + ImGui::SameLine(std::max(ImGui::GetCursorPosX() + ui(8.0f), + ImGui::GetContentRegionMax().x - parent_width - ui(5.0f))); + 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; @@ -1128,20 +2229,24 @@ void draw_commit_details() { else if (file.kind == FileChangeKind::deleted) ++deleted; 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 (deleted) { if (modified || added) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::deleted), ICON_FA_MINUS " %d deleted", deleted); } + if (modified) ImGui::TextColored(change_color(FileChangeKind::modified), ICON_TB_PEN " %d modified", modified); + if (added) { if (modified) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::added), ICON_TB_PLUS " %d added", added); } + if (deleted) { if (modified || added) ImGui::SameLine(); ImGui::TextColored(change_color(FileChangeKind::deleted), ICON_TB_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(); } void draw_details(float width) { - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(39 / 255.0f, 42 / 255.0f, 49 / 255.0f, 1.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(10.0f), ui(8.0f)}); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(33 / 255.0f, 36 / 255.0f, 43 / 255.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(15.0f), ui(8.0f)}); + if (g_reset_repository_view) ImGui::SetNextWindowScroll({0.0f, 0.0f}); ImGui::BeginChild("details", {width, -ui(28.0f)}, ImGuiChildFlags_None); ImGui::PopStyleVar(); if (repo().selected_commit == -1) draw_working_details(); @@ -1156,7 +2261,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 && @@ -1167,14 +2272,21 @@ bool toolbar_action(const char* id, const char* label, const char* icon, const c if (enabled && ImGui::IsItemHovered()) draw->AddRectFilled(minimum, maximum, IM_COL32(62, 66, 75, 210)); 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}, + const float label_font_size = ImGui::GetFontSize() * 0.72f; + const float icon_font_size = ImGui::GetFontSize() * 1.18f; + const ImVec2 label_size = g_regular_font + ? g_regular_font->CalcTextSizeA(label_font_size, std::numeric_limits::max(), 0.0f, label) + : ImGui::CalcTextSize(label); + const ImVec2 icon_size = g_regular_font + ? g_regular_font->CalcTextSizeA(icon_font_size, std::numeric_limits::max(), 0.0f, icon) + : ImGui::CalcTextSize(icon); + draw->AddText(g_regular_font, label_font_size, + {minimum.x + (maximum.x - minimum.x - label_size.x) * 0.5f, minimum.y + ui(2.5f)}, 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(g_regular_font, icon_font_size, {icon_x, minimum.y + ui(21.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_TB_CARET_DOWN); if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { ImGui::BeginTooltip(); ImGui::TextUnformatted(tooltip); @@ -1220,12 +2332,15 @@ 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); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) draw->AddRectFilled(minimum, maximum, IM_COL32(62, 66, 75, 200)); + const float label_font_size = ImGui::GetFontSize() * 0.78f; + draw->AddText(g_regular_font, label_font_size, + {minimum.x + ui(8), minimum.y + ui(4.0f)}, IM_COL32(166, 172, 182, 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,9 +2355,9 @@ 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), - trailing_arrow ? ICON_FA_ANGLE_RIGHT : ICON_FA_CARET_DOWN); + 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_TB_ANGLE_RIGHT : ICON_TB_CARET_DOWN); if (displayed != value && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) ImGui::SetTooltip("%s", value.c_str()); ImGui::PopID(); @@ -1251,18 +2366,24 @@ bool toolbar_selector(const char* id, const char* label, const std::string& valu const char* application_icon(ExternalApplicationId application) { switch (application) { - case ExternalApplicationId::visual_studio_code: return ICON_FA_CODE; - case ExternalApplicationId::visual_studio: return ICON_FA_CUBES; - case ExternalApplicationId::antigravity: return ICON_FA_WINDOW_MAXIMIZE; - case ExternalApplicationId::github_desktop: return ICON_FA_CIRCLE_NODES; - case ExternalApplicationId::file_explorer: return ICON_FA_FOLDER; - case ExternalApplicationId::terminal: return ICON_FA_TERMINAL; - case ExternalApplicationId::git_bash: return ICON_FA_CODE_BRANCH; - case ExternalApplicationId::wsl: return ICON_FA_SERVER; - case ExternalApplicationId::android_studio: return ICON_FA_ROBOT; - case ExternalApplicationId::intellij_idea: return ICON_FA_JET_FIGHTER_UP; + case ExternalApplicationId::visual_studio_code: return ICON_TB_CODE; + case ExternalApplicationId::visual_studio: return ICON_TB_CUBES; + case ExternalApplicationId::antigravity: return ICON_TB_WINDOW_MAXIMIZE; + case ExternalApplicationId::github_desktop: return ICON_TB_CIRCLE_NODES; + case ExternalApplicationId::file_explorer: return ICON_TB_FOLDER; + case ExternalApplicationId::terminal: return ICON_TB_TERMINAL; + case ExternalApplicationId::git_bash: return ICON_TB_CODE_BRANCH; + case ExternalApplicationId::wsl: return ICON_TB_SERVER; + case ExternalApplicationId::android_studio: return ICON_TB_ROBOT; + case ExternalApplicationId::intellij_idea: return ICON_TB_JET_FIGHTER_UP; } - return ICON_FA_ARROW_UP_RIGHT_FROM_SQUARE; + return ICON_TB_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) { @@ -1273,41 +2394,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_TB_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 - ui(1.5f)}, + IM_COL32(177, 184, 194, 255), ICON_TB_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(); @@ -1345,9 +2528,11 @@ void draw_about_popup() { if (!ImGui::BeginPopupModal("About Gitree", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return; ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.38f, 0.88f, 0.94f, 1.0f)); + ImGui::PushFont(g_bold_font, 0.0f); ImGui::SetWindowFontScale(1.35f); ImGui::TextUnformatted("Gitree"); ImGui::SetWindowFontScale(1.0f); + ImGui::PopFont(); ImGui::PopStyleColor(); ImGui::TextDisabled("Version %s", GITREE_VERSION); ImGui::Dummy({ui(460), ui(8)}); @@ -1383,8 +2568,6 @@ void draw_licenses_popup() { {"iZo", "MIT License", "https://dock-it.dev/Idea-Studios/iZo"}, {"iKv", "CC BY-SA 4.0", "https://dock-it.dev/Idea-Studios/iKv"}, {"Inter", "SIL Open Font License 1.1", "https://github.com/rsms/inter"}, - {"Open Sans", "Apache License 2.0", "https://github.com/googlefonts/opensans"}, - {"Font Awesome Free", "SIL Open Font License 1.1", "https://github.com/FortAwesome/Font-Awesome"}, {"Tabler Icons", "MIT License", "https://github.com/tabler/tabler-icons"}, }; for (const auto& dependency : dependencies) { @@ -1402,6 +2585,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); + text_height_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 +2700,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(); @@ -1517,17 +2740,163 @@ void draw_git_action_popups() { } void draw_popups() { - if (g_init_popup) { ImGui::OpenPopup("Create repository"); g_init_popup = false; } - if (ImGui::BeginPopupModal("Create repository", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::TextUnformatted("Repository folder"); - ImGui::SetNextItemWidth(ui(520.0f)); - const bool enter = ImGui::InputText("##path", g_path.data(), g_path.size(), ImGuiInputTextFlags_EnterReturnsTrue); - if (enter || ImGui::Button("Create", {ui(90), 0})) { - if (init_repository(g_path.data())) ImGui::CloseCurrentPopup(); - } + static int gitignore_template = 0; + static int license_template = 0; + static bool initialize_lfs = false; + if (g_init_popup) { + g_create_repository_name.fill('\0'); + set_path_buffer(g_create_repository_parent, default_repository_parent()); + std::snprintf(g_create_repository_branch.data(), g_create_repository_branch.size(), "main"); + gitignore_template = 0; + license_template = 0; + initialize_lfs = false; + g_init_popup = false; + ImGui::OpenPopup("Initialize a Repo"); + } + ImGui::SetNextWindowSize({ui(720.0f), ui(405.0f)}, ImGuiCond_Appearing); + if (ImGui::BeginPopupModal("Initialize a Repo", nullptr, ImGuiWindowFlags_NoResize)) { + const float label_width = ui(145.0f); + const float field_width = ui(555.0f); + const auto field_label = [&](const char* label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + }; + field_label("Name"); + ImGui::SetNextItemWidth(field_width); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + ImGui::InputText("##create_repository_name", g_create_repository_name.data(), + g_create_repository_name.size()); + field_label("Initialize in"); + ImGui::SetNextItemWidth(field_width - ui(82.0f)); + ImGui::InputText("##create_repository_parent", g_create_repository_parent.data(), + g_create_repository_parent.size()); ImGui::SameLine(); - if (ImGui::Button("Cancel", {ui(90), 0})) ImGui::CloseCurrentPopup(); - if (!g_notice.empty()) ImGui::TextColored(ImVec4(0.96f, 0.55f, 0.35f, 1), "%s", g_notice.c_str()); + if (ImGui::Button("Browse", {ui(72.0f), 0})) + pick_folder_into(g_create_repository_parent, "Choose repository parent folder"); + + const std::filesystem::path full_path = std::filesystem::path(g_create_repository_parent.data()) / + g_create_repository_name.data(); + field_label("Full path"); + ImGui::TextDisabled("%s", full_path.string().c_str()); + field_label("Default branch name"); + ImGui::SetNextItemWidth(field_width); + ImGui::InputTextWithHint("##create_repository_branch", "main", + g_create_repository_branch.data(), g_create_repository_branch.size()); + + constexpr const char* gitignore_options[] = {"None", "C / C++", "C#", "Python", "Node"}; + field_label(".gitignore template"); + ImGui::SetNextItemWidth(field_width); + ImGui::Combo("##gitignore_template", &gitignore_template, gitignore_options, + static_cast(std::size(gitignore_options))); + constexpr const char* license_options[] = {"None", "MIT", "Apache 2.0", "GPL-3.0"}; + field_label("License"); + ImGui::SetNextItemWidth(field_width); + ImGui::Combo("##license_template", &license_template, license_options, + static_cast(std::size(license_options))); + ImGui::SetCursorPosX(label_width); + text_height_checkbox("Initialize with Git LFS", &initialize_lfs); + + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - ui(48.0f)); + const float button_width = ui(125.0f); + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - button_width * 2.0f - ui(22.0f)); + if (ImGui::Button("Cancel", {button_width, ui(30.0f)})) ImGui::CloseCurrentPopup(); + ImGui::SameLine(); + const bool can_create = g_create_repository_name[0] && g_create_repository_parent[0] && + g_create_repository_branch[0]; + ImGui::BeginDisabled(!can_create); + if (ImGui::Button("Create Repository", {button_width, ui(30.0f)}) && can_create) { + if (repo().repo) create_new_tab(); + if (init_repository(full_path.string().c_str(), g_create_repository_branch.data())) { + static constexpr const char* gitignores[] = { + "", "build/\n*.obj\n*.o\n*.exe\n.vs/\n.idea/\n", + "bin/\nobj/\n.vs/\n*.user\n", "__pycache__/\n*.py[cod]\n.venv/\n", + "node_modules/\ndist/\n.env\n", + }; + static constexpr const char* licenses[] = { + "", "MIT License\n\nCopyright (c) 2026\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files to deal in the Software without restriction.\n", + "Apache License\nVersion 2.0, January 2004\nhttps://www.apache.org/licenses/LICENSE-2.0\n", + "GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007\nhttps://www.gnu.org/licenses/gpl-3.0.txt\n", + }; + if (gitignore_template > 0) + std::ofstream(full_path / ".gitignore") << gitignores[gitignore_template]; + if (license_template > 0) + std::ofstream(full_path / "LICENSE") << licenses[license_template]; + if (initialize_lfs) { + std::string output; + g_git_manager->captureGit(repo(), {"lfs", "install", "--local"}, output, g_notice); + } + g_git_manager->reload(repo(), g_notice); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndDisabled(); + ImGui::EndPopup(); + } + + static bool shallow_clone = false; + static bool sparse_checkout = false; + if (g_clone_popup) { + set_path_buffer(g_clone_repository_parent, default_repository_parent()); + g_clone_repository_url.fill('\0'); + shallow_clone = false; + sparse_checkout = false; + g_clone_popup = false; + ImGui::OpenPopup("Clone a Repo"); + } + ImGui::SetNextWindowSize({ui(730.0f), ui(330.0f)}, ImGuiCond_Appearing); + if (ImGui::BeginPopupModal("Clone a Repo", nullptr, ImGuiWindowFlags_NoResize)) { + const float label_width = ui(165.0f); + const float field_width = ui(540.0f); + const auto field_label = [&](const char* label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + }; + field_label("Where to clone to"); + ImGui::SetNextItemWidth(field_width - ui(82.0f)); + ImGui::InputText("##clone_parent", g_clone_repository_parent.data(), g_clone_repository_parent.size()); + ImGui::SameLine(); + if (ImGui::Button("Browse", {ui(72.0f), 0})) + pick_folder_into(g_clone_repository_parent, "Choose clone destination"); + field_label("URL"); + ImGui::SetNextItemWidth(field_width); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + ImGui::InputText("##clone_url", g_clone_repository_url.data(), g_clone_repository_url.size()); + ImGui::SetCursorPosX(label_width); + text_height_checkbox("Shallow clone", &shallow_clone); + ImGui::SetCursorPosX(label_width); + text_height_checkbox("Sparse checkout", &sparse_checkout); + + ImGui::SetCursorPosY(ImGui::GetWindowHeight() - ui(48.0f)); + const float button_width = ui(120.0f); + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - button_width * 2.0f - ui(22.0f)); + if (ImGui::Button("Cancel", {button_width, ui(30.0f)})) ImGui::CloseCurrentPopup(); + ImGui::SameLine(); + const bool can_clone = g_clone_repository_parent[0] && g_clone_repository_url[0]; + ImGui::BeginDisabled(!can_clone); + if (ImGui::Button("Clone Repository", {button_width, ui(30.0f)}) && can_clone) { + std::string repository_name = g_clone_repository_url.data(); + while (!repository_name.empty() && (repository_name.back() == '/' || repository_name.back() == '\\')) + repository_name.pop_back(); + const size_t separator = repository_name.find_last_of("/\\:"); + if (separator != std::string::npos) repository_name.erase(0, separator + 1); + if (repository_name.ends_with(".git")) repository_name.resize(repository_name.size() - 4); + const std::filesystem::path destination = + std::filesystem::path(g_clone_repository_parent.data()) / repository_name; + if (repo().repo) create_new_tab(); + if (g_git_manager->cloneRepository(repo(), g_clone_repository_url.data(), + destination.string(), shallow_clone, g_notice)) { + if (g_user_data) g_user_data->addRecentRepository(destination.string()); + if (sparse_checkout) { + std::string output; + g_git_manager->captureGit(repo(), {"sparse-checkout", "init", "--cone"}, output, g_notice); + } + persist_repository_session(); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndDisabled(); ImGui::EndPopup(); } draw_about_popup(); @@ -1539,57 +2908,133 @@ void draw_footer() { ImGui::Separator(); ImGui::TextDisabled("%s", g_notice.empty() ? "Ready" : g_notice.c_str()); const char* version = "Gitree " GITREE_VERSION; - ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(version).x - ui(18.0f)); + const std::string zoom_label = std::string(ICON_TB_MAGNIFYING_GLASS " ") + + std::to_string(g_zoom_percent) + "%"; + const float zoom_width = ImGui::CalcTextSize(zoom_label.c_str()).x + ui(12.0f); + const float version_width = ImGui::CalcTextSize(version).x; + ImGui::SameLine(ImGui::GetWindowWidth() - zoom_width - version_width - ui(34.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.21f, 0.27f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ui(6.0f), 0.0f}); + if (ImGui::Button(zoom_label.c_str(), {zoom_width, 0})) ImGui::OpenPopup("zoom_selector"); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("UI zoom (Ctrl+mouse wheel is not required)"); + if (ImGui::BeginPopup("zoom_selector")) { + constexpr int zoom_levels[] = {200, 175, 150, 140, 130, 120, 110, 100, 90, 80}; + for (const int zoom : zoom_levels) { + const std::string label = std::to_string(zoom) + "%"; + if (ImGui::Selectable(label.c_str(), zoom == g_zoom_percent)) { + g_zoom_percent = zoom; + g_zoom_reload_requested = true; + if (g_user_data) g_user_data->setZoomPercent(zoom); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(0, ui(12.0f)); ImGui::TextDisabled("%s", version); } void draw_new_tab() { - ImGui::BeginChild("new_tab", {-1, -ui(28.0f)}, ImGuiChildFlags_Borders); - ImGui::Dummy({0, ui(36)}); - ImGui::SetCursorPosX(ui(42)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.38f, 0.88f, 0.94f, 1.0f)); - ImGui::SetWindowFontScale(1.45f); - ImGui::TextUnformatted(ICON_FA_CODE_BRANCH " New Tab"); + ImGui::BeginChild("new_tab", {-1, -ui(28.0f)}, ImGuiChildFlags_None); + const float margin = ui(84.0f); + ImGui::SetCursorPos({margin, ui(28.0f)}); + ImGui::PushFont(g_bold_font, 0.0f); + ImGui::SetWindowFontScale(1.25f); + ImGui::TextUnformatted("Repositories"); ImGui::SetWindowFontScale(1.0f); - ImGui::PopStyleColor(); - ImGui::SetCursorPosX(ui(42)); - ImGui::TextDisabled("Open a repository or return to a recently closed tab."); - ImGui::SetCursorPosX(ui(42)); - if (ImGui::Button(ICON_FA_FOLDER_OPEN " Open repository", {ui(190), ui(38)})) + ImGui::PopFont(); + ImGui::SetCursorPosX(margin); + if (ImGui::Button(ICON_TB_FOLDER_OPEN " Open", {ui(92.0f), ui(36.0f)})) pick_and_open_repository(); + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_DOWNLOAD " Clone", {ui(96.0f), ui(36.0f)})) + g_clone_popup = true; + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_PLUS " Create", {ui(100.0f), ui(36.0f)})) + g_init_popup = true; - ImGui::Dummy({0, ui(24)}); - ImGui::SetCursorPosX(ui(42)); - ImGui::TextUnformatted("RECENTLY CLOSED"); - ImGui::SetCursorPosX(ui(42)); - ImGui::BeginChild("recently_closed", {-ui(42), ui(300)}, ImGuiChildFlags_Borders); - const auto& recent = g_user_data->recentlyClosed(); - if (recent.empty()) { - ImGui::TextDisabled("No recently closed repositories yet."); - } else { - for (int i = 0; i < static_cast(recent.size()); ++i) { - const std::filesystem::path path(recent[i]); - std::string name = path.filename().string(); - if (name.empty()) name = path.parent_path().filename().string(); - ImGui::PushID(i); - if (ImGui::Selectable((std::string(ICON_FA_CLOCK_ROTATE_LEFT) + " " + name).c_str(), false, - ImGuiSelectableFlags_AllowDoubleClick, {0, ui(42)})) { - open_repository(recent[i].c_str()); - } - if (ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Open repository")) open_repository(recent[i].c_str()); - if (ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Reveal in file manager")) { - 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()); - ImGui::EndPopup(); - } - ImGui::SameLine(ui(260)); - ImGui::TextDisabled("%s", recent[i].c_str()); - ImGui::PopID(); + ImGui::SetCursorPosX(margin); + ImGui::SetNextItemWidth(std::max(ui(280.0f), ImGui::GetContentRegionAvail().x - margin)); + ImGui::InputTextWithHint("##repository_filter", ICON_TB_MAGNIFYING_GLASS " Search repositories", + g_repository_filter.data(), g_repository_filter.size()); + ImGui::Dummy({0, ui(7.0f)}); + ImGui::SetCursorPosX(margin); + ImGui::PushFont(g_bold_font, 0.0f); + ImGui::TextUnformatted("Recent"); + ImGui::PopFont(); + + static std::filesystem::path discovered_root; + static std::vector discovered_repositories; + const std::filesystem::path root = default_repository_parent(); + if (discovered_root != root) { + discovered_root = root; + discovered_repositories.clear(); + std::error_code error; + for (std::filesystem::directory_iterator entry(root, + std::filesystem::directory_options::skip_permission_denied, error), end; + !error && entry != end; entry.increment(error)) { + if (!entry->is_directory(error)) continue; + if (std::filesystem::is_directory(entry->path() / ".git", error)) + discovered_repositories.push_back(entry->path().string()); + error.clear(); } + std::ranges::sort(discovered_repositories, {}, [](const std::string& path) { + return std::filesystem::path(path).filename().string(); + }); } + + std::vector repositories; + std::set unique_paths; + const auto add_repository = [&](const std::string& path) { + if (!path.empty() && unique_paths.insert(path).second) repositories.push_back(path); + }; + if (g_user_data) + for (const std::string& path : g_user_data->recentRepositories()) add_repository(path); + for (const std::string& path : discovered_repositories) add_repository(path); + + ImGui::SetCursorPosX(margin); + ImGui::BeginChild("repository_list", {-margin, -ui(8.0f)}, ImGuiChildFlags_None); + int visible_index = 0; + for (const std::string& repository_path : repositories) { + const std::filesystem::path path(repository_path); + std::string name = path.filename().string(); + if (name.empty()) name = path.parent_path().filename().string(); + if (g_repository_filter[0] && + !contains_case_insensitive(name, g_repository_filter.data()) && + !contains_case_insensitive(repository_path, g_repository_filter.data())) + continue; + ImGui::PushID(visible_index++); + const bool clicked = ImGui::Selectable("##repository", false, + ImGuiSelectableFlags_None, {0, ui(22.0f)}); + const ImVec2 minimum = ImGui::GetItemRectMin(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + const float row_font_size = ImGui::GetFontSize(); + draw->AddText(g_bold_font, row_font_size, + {minimum.x + ui(3.0f), minimum.y + ui(2.0f)}, + IM_COL32(45, 192, 238, 255), name.c_str()); + const float name_width = g_bold_font->CalcTextSizeA( + row_font_size, std::numeric_limits::max(), 0.0f, name.c_str()).x; + draw->AddText({minimum.x + name_width + ui(14.0f), minimum.y + ui(2.0f)}, + IM_COL32(137, 143, 154, 255), repository_path.c_str()); + if (clicked) open_repository(repository_path.c_str()); + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem(ICON_TB_FOLDER_OPEN " Open repository")) + open_repository(repository_path.c_str()); + if (ImGui::MenuItem(ICON_TB_FOLDER_OPEN " Reveal in file manager")) { + std::string error; + if (!izo::RevealInFileManager(repository_path, &error)) g_notice = error; + } + if (ImGui::MenuItem(ICON_TB_COPY " Copy path")) + copy_to_clipboard(repository_path, "path"); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + if (visible_index == 0) ImGui::TextDisabled("No repositories found."); ImGui::EndChild(); ImGui::EndChild(); } @@ -1601,8 +3046,20 @@ void draw_app() { ImGui::Begin("Gitree", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_MenuBar); + const bool control_down = ImGui::GetIO().KeyCtrl && !ImGui::GetIO().KeyAlt; + bool create_tab_requested = control_down && !ImGui::GetIO().KeyShift && + ImGui::IsKeyPressed(ImGuiKey_T, false); + bool reopen_tab_requested = control_down && ImGui::GetIO().KeyShift && + ImGui::IsKeyPressed(ImGuiKey_T, false); + bool close_tab_requested = control_down && ImGui::IsKeyPressed(ImGuiKey_W, false); if (ImGui::BeginMenuBar()) { if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New tab", "Ctrl+T")) create_tab_requested = true; + if (ImGui::MenuItem("Close tab", "Ctrl+W")) close_tab_requested = true; + ImGui::BeginDisabled(!g_user_data || g_user_data->recentlyClosed().empty()); + if (ImGui::MenuItem("Reopen closed tab", "Ctrl+Shift+T")) reopen_tab_requested = true; + ImGui::EndDisabled(); + ImGui::Separator(); if (ImGui::MenuItem("Open repository...", "Ctrl+O")) pick_and_open_repository(); if (ImGui::MenuItem("Create repository...", "Ctrl+N")) g_init_popup = true; ImGui::Separator(); @@ -1621,30 +3078,48 @@ void draw_app() { ImGui::EndMenuBar(); } + if (close_tab_requested && !g_tabs.empty()) close_tab(g_active_tab); + if (reopen_tab_requested && g_user_data) { + const std::string path = g_user_data->takeRecentlyClosed(); + if (!path.empty()) { + const bool created_tab = repo().repo != nullptr || !repo().path.empty(); + if (created_tab) create_new_tab(); + if (!open_repository(path.c_str())) { + g_user_data->addRecentlyClosed(path); + if (created_tab) close_tab(g_active_tab); + } + } + } + if (create_tab_requested) create_new_tab(); + size_t tab_to_close = g_tabs.size(); size_t tab_move_from = g_tabs.size(); size_t tab_move_to = g_tabs.size(); bool add_tab = false; - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {ImGui::GetStyle().FramePadding.x, ui(5.0f)}); + const float current_tab_height = ImGui::GetFontSize() + ui(10.0f); + const float taller_tab_padding = (current_tab_height * 1.10f - ImGui::GetFontSize()) * 0.5f; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, + {ImGui::GetStyle().FramePadding.x, taller_tab_padding}); + 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(43, 47, 56, 255)); if (ImGui::BeginTabBar("repositories", ImGuiTabBarFlags_AutoSelectNewTabs)) { for (size_t i = 0; i < g_tabs.size(); ++i) { ImGui::PushID(g_tabs[i].get()); bool open = true; - const std::string label = g_tabs[i]->name + "###repo_tab"; + const std::string label = (g_tabs[i]->repo ? std::string(ICON_TB_CODE_BRANCH " ") : "") + + g_tabs[i]->name + "###repo_tab"; const ImGuiTabItemFlags flags = (g_tabs[i].get() == g_tab_to_select ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None) | ImGuiTabItemFlags_NoReorder; + ImGui::PushFont(g_bold_font, 0.0f); if (ImGui::BeginTabItem(label.c_str(), &open, flags)) { - if (g_active_tab != i) { - g_active_tab = i; - persist_repository_session(); - } + if (g_active_tab != i) activate_repository_tab(i); ImGui::EndTabItem(); } + ImGui::PopFont(); if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { ImGui::SetDragDropPayload("GITREE_REPOSITORY_TAB", &i, sizeof(i)); ImGui::TextUnformatted(g_tabs[i]->name.c_str()); @@ -1658,37 +3133,39 @@ void draw_app() { ImGui::EndDragDropTarget(); } if (ImGui::BeginPopupContextItem()) { - if (!g_tabs[i]->path.empty() && ImGui::MenuItem(ICON_FA_FOLDER_OPEN " Reveal in file manager")) { + if (!g_tabs[i]->path.empty() && ImGui::MenuItem(ICON_TB_FOLDER_OPEN " Reveal in file manager")) { std::string error; 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()); + if (!g_tabs[i]->path.empty() && ImGui::MenuItem(ICON_TB_COPY " Copy repository path")) + copy_to_clipboard(g_tabs[i]->path, "repository path"); ImGui::Separator(); - if (ImGui::MenuItem(ICON_FA_XMARK " Close tab")) tab_to_close = i; + if (ImGui::MenuItem(ICON_TB_XMARK " Close tab")) tab_to_close = i; ImGui::EndPopup(); } if (!open) tab_to_close = i; 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_TB_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_TB_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]); @@ -1707,55 +3184,97 @@ void draw_app() { draw_new_tab(); draw_footer(); draw_popups(); + g_reset_repository_view = false; ImGui::End(); return; } - 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, + std::string branch_checkout_to_execute; + if (!g_requested_branch_checkout.empty() && g_running_branch_checkout.empty()) { + g_running_branch_checkout = std::move(g_requested_branch_checkout); + g_requested_branch_checkout.clear(); + g_notice = std::string("Switching to branch ") + g_running_branch_checkout + "..."; + } + if (!g_running_branch_checkout.empty()) branch_checkout_to_execute = g_running_branch_checkout; + + const ToolbarActionRequest toolbar_action_to_execute = g_pending_toolbar_action; + if (toolbar_action_to_execute != ToolbarActionRequest::none) { + g_running_toolbar_action = toolbar_action_to_execute; + g_pending_toolbar_action = ToolbarActionRequest::none; + } + + const bool code_viewer_open = g_diff_viewer.isOpen(); + if (code_viewer_open && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + g_diff_viewer.close(); + } + if (code_viewer_open && !g_previous_code_viewer_open) { + g_sidebar_collapsed_before_viewer = g_sidebar_collapsed; + if (!g_sidebar_collapsed) { + g_sidebar_collapsed = true; + g_sidebar_auto_collapsed = true; + } else { + g_sidebar_auto_collapsed = false; + } + } else if (!code_viewer_open && g_previous_code_viewer_open) { + if (g_sidebar_auto_collapsed) g_sidebar_collapsed = g_sidebar_collapsed_before_viewer; + g_sidebar_auto_collapsed = false; + } + g_previous_code_viewer_open = code_viewer_open; + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ImGui::GetStyle().ItemSpacing.x, 0.0f}); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(46 / 255.0f, 50 / 255.0f, 59 / 255.0f, 1.0f)); + 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)}); + ImGui::PushFont(g_bold_font, 0.0f); 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)) + const bool repository_selector_clicked = + toolbar_selector("repository", "repository", repo().name, repository_width); + ImGui::PopFont(); + if (repository_selector_clicked) ImGui::OpenPopup("repository_selector"); if (ImGui::BeginPopup("repository_selector")) { for (size_t index = 0; index < g_tabs.size(); ++index) { if (ImGui::MenuItem(g_tabs[index]->name.c_str(), nullptr, index == g_active_tab)) { - g_active_tab = index; - g_tab_to_select = g_tabs[index].get(); - persist_repository_session(); + activate_repository_tab(index); } } ImGui::EndPopup(); } ImGui::SameLine(0, ui(3)); - ImGui::SetCursorPosY(ui(19)); - ImGui::TextDisabled(ICON_FA_ANGLE_RIGHT); + ImGui::SetCursorPosY(ui(23)); + ImGui::TextDisabled(ICON_TB_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; } - if (toolbar_selector("branch", "branch", repo().branch, 150.0f)) ImGui::OpenPopup("branch_selector"); + const bool switching_branch = !branch_checkout_to_execute.empty(); + const std::string branch_selector_value = switching_branch + ? std::string(ICON_TB_ROTATE_RIGHT) + " Switching..." + : repo().branch; + if (!switching_branch && toolbar_selector("branch", "branch", branch_selector_value, 150.0f)) + ImGui::OpenPopup("branch_selector"); const float selectors_right = ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x; const ImVec2 branch_popup_size{ui(320.0f), ui(370.0f)}; ImGui::SetNextWindowSize(branch_popup_size, ImGuiCond_Always); ImGui::SetNextWindowSizeConstraints(branch_popup_size, branch_popup_size); - if (ImGui::BeginPopup("branch_selector", ImGuiWindowFlags_NoResize)) { + if (!switching_branch && ImGui::BeginPopup("branch_selector", ImGuiWindowFlags_NoResize)) { if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); ImGui::SetNextItemWidth(-1); - ImGui::InputTextWithHint("##branch_search", ICON_FA_MAGNIFYING_GLASS " Search branches...", + ImGui::InputTextWithHint("##branch_search", ICON_TB_MAGNIFYING_GLASS " Search branches...", g_branch_filter.data(), g_branch_filter.size()); ImGui::Separator(); ImGui::TextDisabled("LOCAL BRANCHES"); ImGui::BeginChild("branch_results", {-1, -1}, ImGuiChildFlags_None); for (const auto& branch : repo().local_branches) { if (!contains_case_insensitive(branch, g_branch_filter.data())) continue; - const std::string item = (branch == repo().branch ? std::string(ICON_FA_CHECK " ") : " ") + branch; + const std::string item = (branch == repo().branch ? std::string(ICON_TB_CHECK " ") : " ") + branch; if (ImGui::Selectable(item.c_str(), branch == repo().branch) && branch != repo().branch) { - g_git_manager->checkoutBranch(repo(), branch, g_notice); + request_branch_checkout(branch); + clear_sidebar_filter(); ImGui::CloseCurrentPopup(); } } @@ -1767,59 +3286,72 @@ 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)}); + const bool toolbar_busy = g_running_toolbar_action != ToolbarActionRequest::none; + 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)) + if (toolbar_action("undo", "Undo", ICON_TB_ROTATE_LEFT, repo().undo_action.tooltip.c_str(), + repo().undo_action.available, false, 54)) g_git_manager->undoCommit(repo(), g_notice); ImGui::SameLine(0, action_spacing); - if (toolbar_action("redo", "Redo", ICON_FA_ROTATE_RIGHT, "Redo last Git action", true, false, 54)) + if (toolbar_action("redo", "Redo", ICON_TB_ROTATE_RIGHT, repo().redo_action.tooltip.c_str(), + repo().redo_action.available, false, 54)) g_git_manager->redoCommit(repo(), g_notice); ImGui::SameLine(0, action_spacing); bool pull_dropdown_clicked = false; - if (toolbar_action("pull", "Pull", ICON_FA_DOWNLOAD, pull_mode_name(g_user_data->pullMode()), - true, true, 58, &pull_dropdown_clicked)) - g_git_manager->pull(repo(), g_user_data->pullMode(), g_notice); + const bool pull_running = g_running_toolbar_action == ToolbarActionRequest::pull; + if (toolbar_action("pull", "Pull", pull_running ? ICON_TB_ROTATE_RIGHT : ICON_TB_DOWNLOAD, + pull_running ? "Pull in progress..." : pull_mode_name(g_user_data->pullMode()), + !toolbar_busy, true, 58, &pull_dropdown_clicked)) + g_pending_toolbar_action = ToolbarActionRequest::pull; if (pull_dropdown_clicked) ImGui::OpenPopup("pull_options"); draw_pull_options(); ImGui::SameLine(0, action_spacing); - if (toolbar_action("push", "Push", ICON_FA_UPLOAD, "Push to remote", true, false, 58)) - g_git_manager->push(repo(), g_notice); + const bool push_running = g_running_toolbar_action == ToolbarActionRequest::push; + if (toolbar_action("push", "Push", push_running ? ICON_TB_ROTATE_RIGHT : ICON_TB_UPLOAD, + push_running ? "Push in progress..." : "Push to remote", !toolbar_busy, false, 58)) + g_pending_toolbar_action = ToolbarActionRequest::push; ImGui::SameLine(0, action_spacing); - if (toolbar_action("branch_action", "Branch", ICON_FA_CODE_BRANCH, "Create branch", true, false, 64)) { + if (toolbar_action("branch_action", "Branch", ICON_TB_CODE_BRANCH, "Create branch", true, false, 64)) { begin_inline_branch(repo().selected_commit >= 0 ? repo().selected_commit : 0); } ImGui::SameLine(0, action_spacing); - if (toolbar_action("stash", "Stash", ICON_FA_BOX_ARCHIVE, "Stash changes", true, false, 58)) + if (toolbar_action("stash", "Stash", ICON_TB_BOX_ARCHIVE, "Stash changes", true, false, 58)) g_git_manager->stash(repo(), g_notice); ImGui::SameLine(0, action_spacing); - if (toolbar_action("pop", "Pop", ICON_FA_BOX_OPEN, "Pop latest stash", true, false, 54)) + if (toolbar_action("pop", "Pop", ICON_TB_BOX_OPEN, "Pop latest stash", true, false, 54)) g_git_manager->popStash(repo(), g_notice); draw_open_in_button(); ImGui::EndChild(); ImGui::PopStyleColor(); + ImGui::PopStyleVar(); - draw_sidebar(ui(g_sidebar_width)); + draw_sidebar(ui(g_sidebar_collapsed ? 44.0f : g_sidebar_width)); ImGui::SameLine(0, 0); - ImGui::InvisibleButton("##sidebar_splitter", {ui(6.0f), -ui(28.0f)}); - if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); - if (ImGui::IsItemActive()) { - g_sidebar_width = std::clamp(g_sidebar_width + ImGui::GetIO().MouseDelta.x / g_ui_scale, 180.0f, 520.0f); + if (!g_sidebar_collapsed) { + ImGui::InvisibleButton("##sidebar_splitter", {ui(6.0f), -ui(28.0f)}); + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + if (ImGui::IsItemActive()) { + g_sidebar_width = std::clamp( + g_sidebar_width + ImGui::GetIO().MouseDelta.x / g_ui_scale, 180.0f, 520.0f); + } + const ImVec2 splitter_min = ImGui::GetItemRectMin(); + const ImVec2 splitter_max = ImGui::GetItemRectMax(); + 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(0, 0, 0, 0), ui(1.0f)); + ImGui::SameLine(0, 0); } - const ImVec2 splitter_min = ImGui::GetItemRectMin(); - const ImVec2 splitter_max = ImGui::GetItemRectMax(); - 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), - ui(1.0f)); - ImGui::SameLine(0, 0); const float content_width = ImGui::GetContentRegionAvail().x; const float details_maximum = std::max(180.0f, std::min(650.0f, (content_width - ui(306.0f)) / g_ui_scale)); g_details_width = std::clamp(g_details_width, std::min(280.0f, details_maximum), details_maximum); const float detail_width = ui(g_details_width); + if (g_reset_repository_view) ImGui::SetNextWindowScroll({0.0f, 0.0f}); ImGui::BeginChild("center", {content_width - detail_width - ui(6.0f), -ui(28.0f)}, false); - if (g_diff_viewer.isOpen()) g_diff_viewer.draw(repo(), *g_git_manager, g_ui_scale, g_notice); + if (g_diff_viewer.isOpen()) + g_diff_viewer.draw(repo(), *g_git_manager, g_avatar_cache, g_ui_scale, g_code_font, g_notice); else draw_commit_table(); ImGui::EndChild(); ImGui::SameLine(0, 0); @@ -1839,13 +3371,26 @@ 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); + g_reset_repository_view = false; draw_footer(); draw_popups(); ImGui::End(); + + if (toolbar_action_to_execute == ToolbarActionRequest::pull) + g_git_manager->pull(repo(), g_user_data->pullMode(), g_notice); + else if (toolbar_action_to_execute == ToolbarActionRequest::push) + g_git_manager->push(repo(), g_notice); + if (toolbar_action_to_execute != ToolbarActionRequest::none) + g_running_toolbar_action = ToolbarActionRequest::none; + + if (!branch_checkout_to_execute.empty()) { + g_git_manager->checkoutBranch(repo(), branch_checkout_to_execute, g_notice); + g_running_branch_checkout.clear(); + } } } // namespace @@ -1860,6 +3405,7 @@ int runGitree(int argc, char** argv) { g_user_data = &user_data; g_sidebar_width = user_data.sidebarWidth(); g_details_width = user_data.detailsWidth(); + g_zoom_percent = user_data.zoomPercent(); if (argc > 1) { create_new_tab(false); @@ -1880,18 +3426,24 @@ 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(); ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - ImGui::GetIO().IniFilename = user_data.imguiIniPath().c_str(); - load_fonts(window_manager.dpiScale()); + ImGui::GetIO().IniFilename = nullptr; + load_fonts(combined_ui_scale(window_manager.dpiScale(), g_zoom_percent)); ImGui_ImplGlfw_InitForOpenGL(window_manager.nativeWindow(), true); ImGui_ImplOpenGL3_Init("#version 330"); while (!window_manager.shouldClose()) { window_manager.pollEvents(); - if (window_manager.consumeDpiChange()) load_fonts(window_manager.dpiScale()); + const bool dpi_changed = window_manager.consumeDpiChange(); + if (dpi_changed || g_zoom_reload_requested) { + load_fonts(combined_ui_scale(window_manager.dpiScale(), g_zoom_percent)); + g_zoom_reload_requested = false; + } ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); @@ -1908,7 +3460,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..c0c65a5 100644 --- a/src/ui/graph_renderer.cpp +++ b/src/ui/graph_renderer.cpp @@ -2,10 +2,98 @@ #include "managers/avatar_cache.h" #include "models/repository.h" -#include +#include #include +#include #include +#include + +namespace { + +template +void drawRoundedPolyline(ImDrawList* draw, const std::array& points, + float radius, ImU32 color, float thickness) { + static_assert(Count >= 2); + draw->PathClear(); + draw->PathLineTo(points.front()); + for (size_t index = 1; index + 1 < Count; ++index) { + const ImVec2 previous = points[index - 1]; + const ImVec2 corner = points[index]; + const ImVec2 next = points[index + 1]; + const float incoming_length = std::hypot(corner.x - previous.x, corner.y - previous.y); + const float outgoing_length = std::hypot(next.x - corner.x, next.y - corner.y); + if (incoming_length < 0.01f || outgoing_length < 0.01f) continue; + const float turn_radius = std::min({radius, incoming_length * 0.5f, outgoing_length * 0.5f}); + const ImVec2 incoming{ + (corner.x - previous.x) / incoming_length, + (corner.y - previous.y) / incoming_length, + }; + const ImVec2 outgoing{ + (next.x - corner.x) / outgoing_length, + (next.y - corner.y) / outgoing_length, + }; + const ImVec2 before{ + corner.x - incoming.x * turn_radius, + corner.y - incoming.y * turn_radius, + }; + const ImVec2 after{ + corner.x + outgoing.x * turn_radius, + corner.y + outgoing.y * turn_radius, + }; + constexpr float control = 0.55228475f; + draw->PathLineTo(before); + draw->PathBezierCubicCurveTo( + {before.x + incoming.x * turn_radius * control, + before.y + incoming.y * turn_radius * control}, + {after.x - outgoing.x * turn_radius * control, + after.y - outgoing.y * turn_radius * control}, + after); + } + draw->PathLineTo(points.back()); + draw->PathStroke(color, ImDrawFlags_None, thickness); +} + +void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent, + ImU32 color, float scale, int route_slot, float detour_x) { + const float thickness = 2.0f * scale; + const float horizontal = parent.x - child.x; + const float vertical = parent.y - child.y; + if (vertical <= 0.0f) { + draw->AddLine(child, parent, color, thickness); + return; + } + + if (std::isfinite(detour_x)) { + if (std::abs(detour_x - child.x) < 0.5f * scale) { + drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, parent.y}, parent}, + 7.0f * scale, color, thickness); + } else if (std::abs(detour_x - parent.x) < 0.5f * scale) { + drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, child.y}, parent}, + 7.0f * scale, color, thickness); + } else { + drawRoundedPolyline(draw, + std::array{child, ImVec2{detour_x, child.y}, ImVec2{detour_x, parent.y}, parent}, + 7.0f * scale, color, thickness); + } + return; + } + + if (std::abs(horizontal) < 0.5f * scale) { + draw->AddLine(child, parent, color, thickness); + return; + } + + // Bends remain anchored to commit rows, but use a compact quarter-round turn. + if (route_slot == 0) + drawRoundedPolyline(draw, std::array{child, ImVec2{child.x, parent.y}, parent}, + 7.0f * scale, color, thickness); + else + drawRoundedPolyline(draw, std::array{child, ImVec2{parent.x, child.y}, parent}, + 7.0f * scale, color, thickness); +} + +} // namespace ImU32 GraphRenderer::laneColor(int lane, int alpha) { static constexpr ImVec4 colors[] = { @@ -25,35 +113,47 @@ 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); + // Keep one spare lane available for edges that must route around occupied commit lanes. + return std::max((68.0f + maximum_lane * 22.0f) * scale, 82.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)); + {ribbon_left, ribbon_top}, + {cell_right, ribbon_bottom}, + laneColor(commit.graph_color, 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)); + {cell_right - px(1.0f), ribbon_top}, + {cell_right - px(1.0f), ribbon_bottom}, + laneColor(commit.graph_color, 215), px(2.0f)); + if (ref_connector_start) { + const ImVec2 end{x, y}; + const float turn_x = end.x - px(8.0f); + draw->PushClipRectFullScreen(); + drawRoundedPolyline(draw, + std::array{*ref_connector_start, ImVec2{turn_x, ref_connector_start->y}, + ImVec2{turn_x, end.y}, end}, + px(5.0f), laneColor(commit.graph_color, 190), px(1.8f)); + draw->PopClipRect(); + } std::vector row_offsets(row_heights.size() + 1, 0.0f); for (size_t index = 0; index < row_heights.size(); ++index) @@ -72,49 +172,81 @@ 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 int preferred_lane = parent_index == 0 ? child.lane : parent.lane; + const auto lane_is_blocked = [&](int candidate_lane) { + for (int intermediate = child_row + 1; intermediate < parent_row; ++intermediate) { + if (row_heights[static_cast(intermediate)] > 0.0f && + commits[static_cast(intermediate)].lane == candidate_lane) + return true; + } + return false; + }; + float detour_x = std::numeric_limits::quiet_NaN(); + if (lane_is_blocked(preferred_lane)) { + int maximum_lane = 0; + for (int intermediate = child_row; intermediate <= parent_row; ++intermediate) { + if (row_heights[static_cast(intermediate)] > 0.0f) + maximum_lane = std::max(maximum_lane, + commits[static_cast(intermediate)].lane); + } + int detour_lane = maximum_lane + 1; + for (int distance = 1; distance <= maximum_lane + 1; ++distance) { + const int left = preferred_lane - distance; + const int right = preferred_lane + distance; + if (left >= 0 && !lane_is_blocked(left)) { + detour_lane = left; + break; + } + if (right <= maximum_lane && !lane_is_blocked(right)) { + detour_lane = right; + break; + } + } + detour_x = origin.x + px(17.0f) + lane_spacing * detour_lane; } - - 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)); + // Colors are lane-position based, so edges use the visible lane slot they route through. + const int edge_color = std::isfinite(detour_x) + ? static_cast(std::lround((detour_x - (origin.x + px(17.0f))) / lane_spacing)) + : preferred_lane; + drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y}, + laneColor(edge_color, 235), scale_, static_cast(parent_index), detour_x); } } 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; @@ -124,8 +256,9 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit, {x + radius - px(1.5f), y + radius - px(1.5f)}, {0, 0}, {1, 1}, IM_COL32_WHITE, radius); } else { - 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); + const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_USER); + draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_TB_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_; } diff --git a/vendor/fonts/Inter-Regular.ttf b/vendor/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..438c701 Binary files /dev/null and b/vendor/fonts/Inter-Regular.ttf differ diff --git a/vendor/fonts/Inter-SemiBold.ttf b/vendor/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..ab21c99 Binary files /dev/null and b/vendor/fonts/Inter-SemiBold.ttf differ diff --git a/vendor/fonts/tabler-icons-outline.ttf b/vendor/fonts/tabler-icons-outline.ttf index a940bee..00ab72c 100644 Binary files a/vendor/fonts/tabler-icons-outline.ttf and b/vendor/fonts/tabler-icons-outline.ttf differ diff --git a/vendor/glfw b/vendor/glfw index 7b6aead..567b1ec 160000 --- a/vendor/glfw +++ b/vendor/glfw @@ -1 +1 @@ -Subproject commit 7b6aead9fb88b3623e3b3725ebb42670cbe4c579 +Subproject commit 567b1ec2442d59525e24c19e8d413df6baf02496 diff --git a/vendor/iZo b/vendor/iZo index 80c6bfc..355a757 160000 --- a/vendor/iZo +++ b/vendor/iZo @@ -1 +1 @@ -Subproject commit 80c6bfce901a2e406b25a0441c8da844e2cc05eb +Subproject commit 355a757a178ddaf4b5124fd3555bede9f7eb95c5 diff --git a/vendor/icons/IconsTabler.h b/vendor/icons/IconsTabler.h new file mode 100644 index 0000000..6747681 --- /dev/null +++ b/vendor/icons/IconsTabler.h @@ -0,0 +1,59 @@ +#pragma once + +// Tabler Icons 3.44.0 outline webfont glyphs used by Gitree. +#define ICON_MIN_TB 0xEA06 +#define ICON_MAX_TB 0xFAF7 + +#define ICON_TB_ANGLE_RIGHT "\xee\xa9\xa1" +#define ICON_TB_ARROW_DOWN "\xee\xa8\x96" +#define ICON_TB_ARROW_DOWN_A_Z "\xee\xbc\x98" +#define ICON_TB_ARROW_DOWN_Z_A "\xee\xbc\x9a" +#define ICON_TB_ARROW_RIGHT_ARROW_LEFT "\xef\x87\xb4" +#define ICON_TB_ARROW_UP "\xee\xa8\xa5" +#define ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE "\xee\xaa\x99" +#define ICON_TB_BARS "\xee\xb1\x82" +#define ICON_TB_BOX_ARCHIVE "\xee\xa8\x8b" +#define ICON_TB_BOX_OPEN "\xef\x81\xba" +#define ICON_TB_CARET_DOWN "\xee\xad\x9d" +#define ICON_TB_CHECK "\xee\xa9\x9e" +#define ICON_TB_CHEVRON_DOWN "\xee\xa9\x9f" +#define ICON_TB_CHEVRON_RIGHT "\xee\xa9\xa1" +#define ICON_TB_CIRCLE_CHEVRON_LEFT "\xef\x98\xa3" +#define ICON_TB_CIRCLE_CHEVRON_RIGHT "\xef\x98\xa4" +#define ICON_TB_CIRCLE_DOT "\xee\xbe\xb1" +#define ICON_TB_CIRCLE_NODES "\xee\xaa\xb5" +#define ICON_TB_CLOCK_ROTATE_LEFT "\xee\xaf\xaa" +#define ICON_TB_CLOUD "\xee\xa9\xb6" +#define ICON_TB_CODE "\xee\xa9\xb7" +#define ICON_TB_CODE_BRANCH "\xee\xaa\xb2" +#define ICON_TB_COMPUTER "\xee\xaa\x89" +#define ICON_TB_COPY "\xee\xa9\xba" +#define ICON_TB_CUBES "\xee\xb8\x97" +#define ICON_TB_DESKTOP "\xee\xaa\x89" +#define ICON_TB_DEVICE_LAPTOP "\xee\xad\xa4" +#define ICON_TB_DOWNLOAD "\xee\xaa\x96" +#define ICON_TB_EYE "\xee\xaa\x9a" +#define ICON_TB_FILE "\xee\xaa\xa4" +#define ICON_TB_FOLDER "\xee\xaa\xad" +#define ICON_TB_FOLDER_OPEN "\xef\xab\xb7" +#define ICON_TB_FOLDER_TREE "\xee\xba\x9d" +#define ICON_TB_GLOBE "\xee\xad\x94" +#define ICON_TB_JET_FIGHTER_UP "\xef\x87\xad" +#define ICON_TB_LAYERS_LINKED "\xee\xba\xa1" +#define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c" +#define ICON_TB_MINUS "\xee\xab\xb2" +#define ICON_TB_PEN "\xee\xac\x84" +#define ICON_TB_PLUS "\xee\xac\x8b" +#define ICON_TB_ROBOT "\xef\x80\x8b" +#define ICON_TB_ROTATE_LEFT "\xee\xac\x96" +#define ICON_TB_ROTATE_RIGHT "\xee\xac\x95" +#define ICON_TB_SERVER "\xee\xac\x9f" +#define ICON_TB_TAG "\xee\xbe\x86" +#define ICON_TB_TERMINAL "\xee\xaf\xaf" +#define ICON_TB_TRASH_CAN "\xee\xad\x81" +#define ICON_TB_TREE "\xee\xbc\x81" +#define ICON_TB_TRIANGLE_EXCLAMATION "\xee\xa8\x86" +#define ICON_TB_UPLOAD "\xee\xad\x87" +#define ICON_TB_USER "\xee\xad\x8d" +#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa" +#define ICON_TB_XMARK "\xee\xad\x95" diff --git a/vendor/imgui b/vendor/imgui index b61e563..d15966f 160000 --- a/vendor/imgui +++ b/vendor/imgui @@ -1 +1 @@ -Subproject commit b61e56346a92cfcaf1f43a545ca37b0b32239654 +Subproject commit d15966ff6cb48adacaae2f6d40230b4194d8ea70 diff --git a/vendor/libgit2 b/vendor/libgit2 index f716426..44c05e5 160000 --- a/vendor/libgit2 +++ b/vendor/libgit2 @@ -1 +1 @@ -Subproject commit f7164261c9bc0a7e0ebf767c584e5192810a8b24 +Subproject commit 44c05e5d12f2b8b86b9730bb50f27daf74143782