diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index aa4d9de..ccd1759 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -1,12 +1,16 @@ -name: Build Windows EXE +name: Build Releases on: push: branches: - "**" +env: + APP_NAME: gitree + jobs: build-windows: + name: Build Windows x64 runs-on: ubuntu-latest steps: @@ -16,6 +20,16 @@ jobs: submodules: recursive fetch-depth: 0 + - name: Generate build names + env: + GITEA_SHA: ${{ gitea.sha }} + run: | + short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)" + + echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV" + echo "WINDOWS_EXE=${APP_NAME}-windows-x64-${short_sha}.exe" >> "$GITHUB_ENV" + echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64-${short_sha}" >> "$GITHUB_ENV" + - name: Install dependencies run: | sudo apt-get update @@ -26,10 +40,13 @@ jobs: cat > mingw-toolchain.cmake <<'EOF' set(CMAKE_SYSTEM_NAME Windows) set(CMAKE_SYSTEM_PROCESSOR x86_64) + set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc) set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++) set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres) + set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) @@ -46,9 +63,32 @@ jobs: - name: Build Windows executable run: cmake --build build-win --parallel + - name: Locate Windows EXE + run: | + exe_path="" + + if [ -f "build-win/bin/${APP_NAME}.exe" ]; then + exe_path="build-win/bin/${APP_NAME}.exe" + elif [ -f "build-win/bin/Gitree.exe" ]; then + exe_path="build-win/bin/Gitree.exe" + elif [ -f "build-win/bin/gitree.exe" ]; then + exe_path="build-win/bin/gitree.exe" + else + exe_path="$(find build-win -type f -iname '*.exe' | head -n 1)" + fi + + if [ -z "$exe_path" ]; then + echo "Could not find built Windows EXE." + exit 1 + fi + + echo "WINDOWS_EXE_PATH=${exe_path}" >> "$GITHUB_ENV" + echo "Found Windows EXE: ${exe_path}" + - name: Verify static runtime linkage run: | - dependencies="$(x86_64-w64-mingw32-objdump -p build-win/bin/gitree.exe | sed -n 's/.*DLL Name: //p')" + dependencies="$(x86_64-w64-mingw32-objdump -p "$WINDOWS_EXE_PATH" | sed -n 's/.*DLL Name: //p')" + printf '%s\n' "$dependencies" if printf '%s\n' "$dependencies" | grep -Eqi 'lib(gcc|stdc\+\+|winpthread).*\.dll'; then @@ -56,36 +96,174 @@ jobs: 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 + - name: Prepare Windows artifact run: | mkdir -p dist - cp build-win/bin/gitree.exe "dist/${EXE_NAME}" + cp "$WINDOWS_EXE_PATH" "dist/${WINDOWS_EXE}" - name: Upload Windows build uses: actions/upload-artifact@v3 with: - name: ${{ env.ARTIFACT_NAME }} - path: dist/${{ env.EXE_NAME }} + name: ${{ env.WINDOWS_ARTIFACT }} + path: dist/${{ env.WINDOWS_EXE }} if-no-files-found: error - - name: Create prod release - if: ${{ gitea.ref_name == 'prod' }} + build-linux: + name: Build Linux x64 DEB + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Generate build names env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - GITEA_SERVER_URL: ${{ gitea.server_url }} - GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_SHA: ${{ gitea.sha }} - GITEA_RUN_NUMBER: ${{ gitea.run_number }} + run: | + short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)" + + echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV" + echo "LINUX_DEB=${APP_NAME}-deb-x64-${short_sha}.deb" >> "$GITHUB_ENV" + echo "LINUX_ARTIFACT=${APP_NAME}-deb-x64-${short_sha}" >> "$GITHUB_ENV" + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build curl jq dpkg-dev + + - name: Configure Linux release build + run: | + cmake -S . -B build-linux -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF + + - name: Build Linux executable + run: cmake --build build-linux --parallel + + - name: Locate Linux executable + run: | + exe_path="" + + if [ -f "build-linux/bin/${APP_NAME}" ]; then + exe_path="build-linux/bin/${APP_NAME}" + elif [ -f "build-linux/bin/Gitree" ]; then + exe_path="build-linux/bin/Gitree" + elif [ -f "build-linux/bin/gitree" ]; then + exe_path="build-linux/bin/gitree" + else + exe_path="$(find build-linux -type f -executable -name "${APP_NAME}" | head -n 1)" + fi + + if [ -z "$exe_path" ]; then + echo "Could not find built Linux executable." + exit 1 + fi + + echo "LINUX_EXE_PATH=${exe_path}" >> "$GITHUB_ENV" + echo "Found Linux executable: ${exe_path}" + + - name: Package Linux DEB + run: | + mkdir -p "pkg/DEBIAN" + mkdir -p "pkg/usr/bin" + + cp "$LINUX_EXE_PATH" "pkg/usr/bin/${APP_NAME}" + chmod 755 "pkg/usr/bin/${APP_NAME}" + + version="0.0.0+${BUILD_HASH}" + + cat > "pkg/DEBIAN/control" < + Description: ${APP_NAME} + ${APP_NAME} Linux x64 build. + EOF + + mkdir -p dist + dpkg-deb --build pkg "dist/${LINUX_DEB}" + + - name: Upload Linux DEB build + uses: actions/upload-artifact@v3 + with: + name: ${{ env.LINUX_ARTIFACT }} + path: dist/${{ env.LINUX_DEB }} + if-no-files-found: error + + release: + name: Create Release + runs-on: ubuntu-latest + needs: + - build-windows + - build-linux + if: ${{ always() && gitea.ref_name == 'prod' }} + + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_SHA: ${{ gitea.sha }} + WINDOWS_RESULT: ${{ needs.build-windows.result }} + LINUX_RESULT: ${{ needs.build-linux.result }} + APP_NAME: gitree + + steps: + - name: Check build results + run: | + echo "Windows result: ${WINDOWS_RESULT}" + echo "Linux result: ${LINUX_RESULT}" + + if [ "$WINDOWS_RESULT" != "success" ] && [ "$LINUX_RESULT" != "success" ]; then + echo "Both Windows and Linux builds failed. Refusing to create release." + exit 1 + fi + + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Generate release names + run: | + short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)" + + echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV" + echo "RELEASE_TAG=release-${short_sha}" >> "$GITHUB_ENV" + echo "RELEASE_NAME=Release ${short_sha}" >> "$GITHUB_ENV" + + echo "WINDOWS_EXE=${APP_NAME}-windows-x64-${short_sha}.exe" >> "$GITHUB_ENV" + echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64-${short_sha}" >> "$GITHUB_ENV" + + echo "LINUX_DEB=${APP_NAME}-deb-x64-${short_sha}.deb" >> "$GITHUB_ENV" + echo "LINUX_ARTIFACT=${APP_NAME}-deb-x64-${short_sha}" >> "$GITHUB_ENV" + + - name: Install release dependencies + run: | + sudo apt-get update + sudo apt-get install -y curl jq + + - name: Download Windows artifact + if: ${{ needs.build-windows.result == 'success' }} + uses: actions/download-artifact@v3 + with: + name: ${{ env.WINDOWS_ARTIFACT }} + path: release-assets + + - name: Download Linux artifact + if: ${{ needs.build-linux.result == 'success' }} + uses: actions/download-artifact@v3 + with: + name: ${{ env.LINUX_ARTIFACT }} + path: release-assets + + - name: Create prod release run: | if [ -z "$GITEA_TOKEN" ]; then echo "The repository secret GITEA_TOKEN is required to publish prod releases." @@ -94,8 +272,6 @@ jobs: 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}" @@ -103,21 +279,16 @@ jobs: 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' + jq -r '[.[] | select(.tag_name | startswith("release-"))][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 @@ -140,6 +311,22 @@ jobs: done fi + echo + echo "## Builds" + echo + + if [ "$WINDOWS_RESULT" = "success" ]; then + echo "- ${WINDOWS_EXE}" + else + echo "- Windows x64 failed" + fi + + if [ "$LINUX_RESULT" = "success" ]; then + echo "- ${LINUX_DEB}" + else + echo "- Linux DEB x64 failed" + fi + echo echo "## Authors" echo @@ -175,9 +362,9 @@ jobs: } > release-notes.md payload="$(jq -n \ - --arg tag "$tag" \ + --arg tag "$RELEASE_TAG" \ --arg sha "$GITEA_SHA" \ - --arg name "${ARTIFACT_NAME}" \ + --arg name "$RELEASE_NAME" \ --rawfile body release-notes.md \ '{ tag_name: $tag, @@ -197,8 +384,18 @@ jobs: 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/${EXE_NAME}" \ - "${api}/releases/${release_id}/assets?name=${EXE_NAME}" \ No newline at end of file + for asset in release-assets/*; do + if [ ! -f "$asset" ]; then + continue + fi + + asset_name="$(basename "$asset")" + + echo "Uploading ${asset_name}" + + curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${asset}" \ + "${api}/releases/${release_id}/assets?name=${asset_name}" + done \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index a596e3f..1d4c893 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,12 @@ [submodule "vendor/libgit2"] path = vendor/libgit2 - url = https://github.com/libgit2/libgit2.git + url = https://dock-it.dev/Idea-Studios/libgit2 [submodule "vendor/imgui"] path = vendor/imgui - url = https://github.com/ocornut/imgui.git + url = https://dock-it.dev/Idea-Studios/imgui [submodule "vendor/glfw"] path = vendor/glfw - url = https://github.com/glfw/glfw.git + url = https://dock-it.dev/Idea-Studios/glfw [submodule "vendor/iZo"] path = vendor/iZo url = https://dock-it.dev/Idea-Studios/iZo diff --git a/src/managers/application_manager.cpp b/src/managers/application_manager.cpp index b0c304e..16fa353 100644 --- a/src/managers/application_manager.cpp +++ b/src/managers/application_manager.cpp @@ -14,20 +14,31 @@ namespace return value && !value->empty() ? std::filesystem::path(*value) : std::filesystem::path{}; } + std::filesystem::path under(const std::filesystem::path &root, const char *relative) + { + return root.empty() ? std::filesystem::path{} : root / relative; + } + 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; @@ -36,6 +47,7 @@ namespace if (iterator->is_regular_file(error) && iterator->path().filename() == filename) return iterator->path(); } + return {}; } } @@ -43,9 +55,14 @@ namespace ApplicationManager::ApplicationManager() { const auto local = izo::GetKnownPath(izo::KnownPath::LocalAppData); + const auto roaming = izo::GetKnownPath(izo::KnownPath::AppData); + const auto program_files = environmentPath("ProgramFiles"); const auto program_files_x86 = environmentPath("ProgramFiles(x86)"); + const auto program_data = environmentPath("ProgramData"); + const auto user_profile = environmentPath("USERPROFILE"); const auto windows = environmentPath("WINDIR"); + const auto chocolatey = environmentPath("ChocolateyInstall"); auto add = [this](ExternalApplicationId id, const char *name, std::filesystem::path executable, std::vector arguments = {}) @@ -55,99 +72,481 @@ ApplicationManager::ApplicationManager() applications_.push_back(targets_.back().info); }; + // ------------------------------------------------------------------------- + // VS Code-style editors + // ------------------------------------------------------------------------- + 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", - })); - 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", - })); + under(local, "Programs/Microsoft VS Code/Code.exe"), + under(program_files, "Microsoft VS Code/Code.exe"), + under(program_files_x86, "Microsoft VS Code/Code.exe"), + under(user_profile, "scoop/apps/vscode/current/Code.exe"), + under(chocolatey, "lib/vscode/tools/Code.exe"), + })); + + add(ExternalApplicationId::visual_studio_code_insiders, "VS Code Insiders", firstExisting({ + under(local, "Programs/Microsoft VS Code Insiders/Code - Insiders.exe"), + under(program_files, "Microsoft VS Code Insiders/Code - Insiders.exe"), + under(user_profile, "scoop/apps/vscode-insiders/current/Code - Insiders.exe"), + })); + + add(ExternalApplicationId::vscodium, "VSCodium", firstExisting({ + under(local, "Programs/VSCodium/VSCodium.exe"), + under(program_files, "VSCodium/VSCodium.exe"), + under(program_files_x86, "VSCodium/VSCodium.exe"), + under(user_profile, "scoop/apps/vscodium/current/VSCodium.exe"), + under(chocolatey, "lib/vscodium/tools/VSCodium.exe"), + })); + + add(ExternalApplicationId::cursor, "Cursor", firstExisting({ + under(local, "Programs/cursor/Cursor.exe"), + under(local, "Programs/Cursor/Cursor.exe"), + under(program_files, "Cursor/Cursor.exe"), + under(user_profile, "scoop/apps/cursor/current/Cursor.exe"), + })); + + add(ExternalApplicationId::windsurf, "Windsurf", firstExisting({ + under(local, "Programs/Windsurf/Windsurf.exe"), + under(program_files, "Windsurf/Windsurf.exe"), + under(user_profile, "scoop/apps/windsurf/current/Windsurf.exe"), + })); + + add(ExternalApplicationId::trae, "Trae", firstExisting({ + under(local, "Programs/Trae/Trae.exe"), + under(program_files, "Trae/Trae.exe"), + })); + + add(ExternalApplicationId::zed, "Zed", firstExisting({ + under(local, "Programs/Zed/Zed.exe"), + under(program_files, "Zed/Zed.exe"), + under(user_profile, "scoop/apps/zed/current/Zed.exe"), + })); + add(ExternalApplicationId::antigravity, "Antigravity", firstExisting({ - local / "Programs/Antigravity/Antigravity.exe", - local / "Antigravity/Antigravity.exe", - program_files / "Antigravity/Antigravity.exe", - })); + under(local, "Programs/Antigravity/Antigravity.exe"), + under(local, "Antigravity/Antigravity.exe"), + under(program_files, "Antigravity/Antigravity.exe"), + })); + + // ------------------------------------------------------------------------- + // Microsoft IDEs + // ------------------------------------------------------------------------- + + add(ExternalApplicationId::visual_studio, "Visual Studio", firstExisting({ + under(program_files, "Microsoft Visual Studio/2022/Community/Common7/IDE/devenv.exe"), + under(program_files, "Microsoft Visual Studio/2022/Professional/Common7/IDE/devenv.exe"), + under(program_files, "Microsoft Visual Studio/2022/Enterprise/Common7/IDE/devenv.exe"), + under(program_files, "Microsoft Visual Studio/2022/Preview/Common7/IDE/devenv.exe"), + + under(program_files_x86, "Microsoft Visual Studio/2019/Community/Common7/IDE/devenv.exe"), + under(program_files_x86, "Microsoft Visual Studio/2019/Professional/Common7/IDE/devenv.exe"), + under(program_files_x86, "Microsoft Visual Studio/2019/Enterprise/Common7/IDE/devenv.exe"), + under(program_files_x86, "Microsoft Visual Studio/2019/Preview/Common7/IDE/devenv.exe"), + })); + + // ------------------------------------------------------------------------- + // GitHub / Windows tools + // ------------------------------------------------------------------------- + std::filesystem::path github_desktop = firstExisting({ - local / "GitHubDesktop/GitHubDesktop.exe", - local / "GitHub Desktop/GitHubDesktop.exe", + under(local, "GitHubDesktop/GitHubDesktop.exe"), + under(local, "GitHub Desktop/GitHubDesktop.exe"), }); + if (github_desktop.empty()) - github_desktop = findExecutable(local / "GitHubDesktop", "GitHubDesktop.exe"); + github_desktop = findExecutable(under(local, "GitHubDesktop"), "GitHubDesktop.exe"); + add(ExternalApplicationId::github_desktop, "GitHub Desktop", std::move(github_desktop)); - add(ExternalApplicationId::file_explorer, "File Explorer", - firstExisting({windows / "explorer.exe"})); - add(ExternalApplicationId::terminal, "Terminal", - firstExisting({local / "Microsoft/WindowsApps/wt.exe", windows / "System32/wt.exe"}), {"-d"}); + + add(ExternalApplicationId::file_explorer, "File Explorer", firstExisting({ + under(windows, "explorer.exe"), + })); + + add(ExternalApplicationId::terminal, "Terminal", firstExisting({ + under(local, "Microsoft/WindowsApps/wt.exe"), + under(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="}); - add(ExternalApplicationId::wsl, "WSL", firstExisting({windows / "System32/wsl.exe"}), {"--cd"}); + under(program_files, "Git/git-bash.exe"), + under(program_files_x86, "Git/git-bash.exe"), + under(user_profile, "scoop/apps/git/current/git-bash.exe"), + }), {"--cd="}); + + add(ExternalApplicationId::wsl, "WSL", firstExisting({ + under(windows, "System32/wsl.exe"), + }), {"--cd"}); + + // ------------------------------------------------------------------------- + // Android + // ------------------------------------------------------------------------- + add(ExternalApplicationId::android_studio, "Android Studio", firstExisting({ - program_files / "Android/Android Studio/bin/studio64.exe", - local / "Programs/Android Studio/bin/studio64.exe", - })); + under(program_files, "Android/Android Studio/bin/studio64.exe"), + under(local, "Programs/Android Studio/bin/studio64.exe"), + under(user_profile, "scoop/apps/android-studio/current/bin/studio64.exe"), + })); + + // ------------------------------------------------------------------------- + // JetBrains IDEs + // ------------------------------------------------------------------------- + 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", + under(program_files, "JetBrains/IntelliJ IDEA 2026.1/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA 2025.3/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA 2025.2/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA 2025.1/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA 2024.3/bin/idea64.exe"), + + under(program_files, "JetBrains/IntelliJ IDEA Community Edition 2026.1/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA Community Edition 2025.3/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA Community Edition 2025.2/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA Community Edition 2025.1/bin/idea64.exe"), + under(program_files, "JetBrains/IntelliJ IDEA Community Edition 2024.3/bin/idea64.exe"), + + under(local, "Programs/IntelliJ IDEA Ultimate/bin/idea64.exe"), + under(local, "Programs/IntelliJ IDEA Community/bin/idea64.exe"), }); + if (idea.empty()) - idea = findExecutable(local / "JetBrains/Toolbox/apps", "idea64.exe"); + idea = findExecutable(under(local, "JetBrains/Toolbox/apps"), "idea64.exe"); + add(ExternalApplicationId::intellij_idea, "IntelliJ IDEA", std::move(idea)); + + std::filesystem::path clion = firstExisting({ + under(program_files, "JetBrains/CLion 2026.1/bin/clion64.exe"), + under(program_files, "JetBrains/CLion 2025.3/bin/clion64.exe"), + under(program_files, "JetBrains/CLion 2025.2/bin/clion64.exe"), + under(program_files, "JetBrains/CLion 2025.1/bin/clion64.exe"), + under(program_files, "JetBrains/CLion 2024.3/bin/clion64.exe"), + under(local, "Programs/CLion/bin/clion64.exe"), + }); + + if (clion.empty()) + clion = findExecutable(under(local, "JetBrains/Toolbox/apps"), "clion64.exe"); + + add(ExternalApplicationId::clion, "CLion", std::move(clion)); + + std::filesystem::path rider = firstExisting({ + under(program_files, "JetBrains/JetBrains Rider 2026.1/bin/rider64.exe"), + under(program_files, "JetBrains/JetBrains Rider 2025.3/bin/rider64.exe"), + under(program_files, "JetBrains/JetBrains Rider 2025.2/bin/rider64.exe"), + under(program_files, "JetBrains/JetBrains Rider 2025.1/bin/rider64.exe"), + under(program_files, "JetBrains/JetBrains Rider 2024.3/bin/rider64.exe"), + under(local, "Programs/Rider/bin/rider64.exe"), + }); + + if (rider.empty()) + rider = findExecutable(under(local, "JetBrains/Toolbox/apps"), "rider64.exe"); + + add(ExternalApplicationId::rider, "Rider", std::move(rider)); + + std::filesystem::path pycharm = firstExisting({ + under(program_files, "JetBrains/PyCharm 2026.1/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm 2025.3/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm 2025.2/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm 2025.1/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm 2024.3/bin/pycharm64.exe"), + + under(program_files, "JetBrains/PyCharm Community Edition 2026.1/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm Community Edition 2025.3/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm Community Edition 2025.2/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm Community Edition 2025.1/bin/pycharm64.exe"), + under(program_files, "JetBrains/PyCharm Community Edition 2024.3/bin/pycharm64.exe"), + + under(local, "Programs/PyCharm Professional/bin/pycharm64.exe"), + under(local, "Programs/PyCharm Community/bin/pycharm64.exe"), + }); + + if (pycharm.empty()) + pycharm = findExecutable(under(local, "JetBrains/Toolbox/apps"), "pycharm64.exe"); + + add(ExternalApplicationId::pycharm, "PyCharm", std::move(pycharm)); + + std::filesystem::path webstorm = firstExisting({ + under(program_files, "JetBrains/WebStorm 2026.1/bin/webstorm64.exe"), + under(program_files, "JetBrains/WebStorm 2025.3/bin/webstorm64.exe"), + under(program_files, "JetBrains/WebStorm 2025.2/bin/webstorm64.exe"), + under(program_files, "JetBrains/WebStorm 2025.1/bin/webstorm64.exe"), + under(program_files, "JetBrains/WebStorm 2024.3/bin/webstorm64.exe"), + under(local, "Programs/WebStorm/bin/webstorm64.exe"), + }); + + if (webstorm.empty()) + webstorm = findExecutable(under(local, "JetBrains/Toolbox/apps"), "webstorm64.exe"); + + add(ExternalApplicationId::webstorm, "WebStorm", std::move(webstorm)); + + std::filesystem::path phpstorm = firstExisting({ + under(program_files, "JetBrains/PhpStorm 2026.1/bin/phpstorm64.exe"), + under(program_files, "JetBrains/PhpStorm 2025.3/bin/phpstorm64.exe"), + under(program_files, "JetBrains/PhpStorm 2025.2/bin/phpstorm64.exe"), + under(program_files, "JetBrains/PhpStorm 2025.1/bin/phpstorm64.exe"), + under(program_files, "JetBrains/PhpStorm 2024.3/bin/phpstorm64.exe"), + under(local, "Programs/PhpStorm/bin/phpstorm64.exe"), + }); + + if (phpstorm.empty()) + phpstorm = findExecutable(under(local, "JetBrains/Toolbox/apps"), "phpstorm64.exe"); + + add(ExternalApplicationId::phpstorm, "PhpStorm", std::move(phpstorm)); + + std::filesystem::path rubymine = firstExisting({ + under(program_files, "JetBrains/RubyMine 2026.1/bin/rubymine64.exe"), + under(program_files, "JetBrains/RubyMine 2025.3/bin/rubymine64.exe"), + under(program_files, "JetBrains/RubyMine 2025.2/bin/rubymine64.exe"), + under(program_files, "JetBrains/RubyMine 2025.1/bin/rubymine64.exe"), + under(program_files, "JetBrains/RubyMine 2024.3/bin/rubymine64.exe"), + under(local, "Programs/RubyMine/bin/rubymine64.exe"), + }); + + if (rubymine.empty()) + rubymine = findExecutable(under(local, "JetBrains/Toolbox/apps"), "rubymine64.exe"); + + add(ExternalApplicationId::rubymine, "RubyMine", std::move(rubymine)); + + std::filesystem::path goland = firstExisting({ + under(program_files, "JetBrains/GoLand 2026.1/bin/goland64.exe"), + under(program_files, "JetBrains/GoLand 2025.3/bin/goland64.exe"), + under(program_files, "JetBrains/GoLand 2025.2/bin/goland64.exe"), + under(program_files, "JetBrains/GoLand 2025.1/bin/goland64.exe"), + under(program_files, "JetBrains/GoLand 2024.3/bin/goland64.exe"), + under(local, "Programs/GoLand/bin/goland64.exe"), + }); + + if (goland.empty()) + goland = findExecutable(under(local, "JetBrains/Toolbox/apps"), "goland64.exe"); + + add(ExternalApplicationId::goland, "GoLand", std::move(goland)); + + std::filesystem::path datagrip = firstExisting({ + under(program_files, "JetBrains/DataGrip 2026.1/bin/datagrip64.exe"), + under(program_files, "JetBrains/DataGrip 2025.3/bin/datagrip64.exe"), + under(program_files, "JetBrains/DataGrip 2025.2/bin/datagrip64.exe"), + under(program_files, "JetBrains/DataGrip 2025.1/bin/datagrip64.exe"), + under(program_files, "JetBrains/DataGrip 2024.3/bin/datagrip64.exe"), + under(local, "Programs/DataGrip/bin/datagrip64.exe"), + }); + + if (datagrip.empty()) + datagrip = findExecutable(under(local, "JetBrains/Toolbox/apps"), "datagrip64.exe"); + + add(ExternalApplicationId::datagrip, "DataGrip", std::move(datagrip)); + + add(ExternalApplicationId::jetbrains_fleet, "JetBrains Fleet", firstExisting({ + under(local, "Programs/Fleet/Fleet.exe"), + under(local, "JetBrains/Fleet/bin/Fleet.exe"), + under(program_files, "JetBrains/Fleet/Fleet.exe"), + })); + + // ------------------------------------------------------------------------- + // Lightweight / general editors + // ------------------------------------------------------------------------- + + add(ExternalApplicationId::sublime_text, "Sublime Text", firstExisting({ + under(program_files, "Sublime Text/sublime_text.exe"), + under(program_files, "Sublime Text 3/sublime_text.exe"), + under(program_files_x86, "Sublime Text/sublime_text.exe"), + under(local, "Programs/Sublime Text/sublime_text.exe"), + under(user_profile, "scoop/apps/sublime-text/current/sublime_text.exe"), + })); + + add(ExternalApplicationId::notepad_plus_plus, "Notepad++", firstExisting({ + under(program_files, "Notepad++/notepad++.exe"), + under(program_files_x86, "Notepad++/notepad++.exe"), + under(user_profile, "scoop/apps/notepadplusplus/current/notepad++.exe"), + under(chocolatey, "lib/notepadplusplus/tools/notepad++.exe"), + })); + + add(ExternalApplicationId::atom, "Atom", firstExisting({ + under(local, "atom/atom.exe"), + under(local, "Programs/Atom/atom.exe"), + under(program_files, "Atom/atom.exe"), + })); + + add(ExternalApplicationId::pulsar, "Pulsar", firstExisting({ + under(local, "Programs/Pulsar/Pulsar.exe"), + under(program_files, "Pulsar/Pulsar.exe"), + })); + + add(ExternalApplicationId::brackets, "Brackets", firstExisting({ + under(program_files, "Brackets/Brackets.exe"), + under(program_files_x86, "Brackets/Brackets.exe"), + })); + + add(ExternalApplicationId::lapce, "Lapce", firstExisting({ + under(local, "Programs/Lapce/Lapce.exe"), + under(program_files, "Lapce/Lapce.exe"), + under(user_profile, "scoop/apps/lapce/current/lapce.exe"), + })); + + add(ExternalApplicationId::lite_xl, "Lite XL", firstExisting({ + under(program_files, "Lite XL/lite-xl.exe"), + under(program_files_x86, "Lite XL/lite-xl.exe"), + under(user_profile, "scoop/apps/lite-xl/current/lite-xl.exe"), + })); + + add(ExternalApplicationId::geany, "Geany", firstExisting({ + under(program_files, "Geany/bin/geany.exe"), + under(program_files_x86, "Geany/bin/geany.exe"), + under(user_profile, "scoop/apps/geany/current/bin/geany.exe"), + })); + + add(ExternalApplicationId::kate, "Kate", firstExisting({ + under(program_files, "Kate/bin/kate.exe"), + under(program_files_x86, "Kate/bin/kate.exe"), + under(user_profile, "scoop/apps/kate/current/bin/kate.exe"), + })); + + // ------------------------------------------------------------------------- + // C/C++ / Java / general IDEs + // ------------------------------------------------------------------------- + + add(ExternalApplicationId::qt_creator, "Qt Creator", firstExisting({ + under(program_files, "Qt/Tools/QtCreator/bin/qtcreator.exe"), + under(program_files_x86, "Qt/Tools/QtCreator/bin/qtcreator.exe"), + under(user_profile, "scoop/apps/qt-creator/current/bin/qtcreator.exe"), + })); + + add(ExternalApplicationId::codeblocks, "Code::Blocks", firstExisting({ + under(program_files, "CodeBlocks/codeblocks.exe"), + under(program_files_x86, "CodeBlocks/codeblocks.exe"), + })); + + add(ExternalApplicationId::dev_cpp, "Dev-C++", firstExisting({ + under(program_files, "Embarcadero/Dev-Cpp/devcpp.exe"), + under(program_files_x86, "Embarcadero/Dev-Cpp/devcpp.exe"), + under(program_files, "Dev-Cpp/devcpp.exe"), + under(program_files_x86, "Dev-Cpp/devcpp.exe"), + })); + + add(ExternalApplicationId::eclipse, "Eclipse", firstExisting({ + under(user_profile, "eclipse/java-latest-released/eclipse/eclipse.exe"), + under(program_files, "Eclipse Foundation/eclipse/eclipse.exe"), + under(program_files, "Eclipse/eclipse.exe"), + under(local, "Programs/Eclipse/eclipse.exe"), + })); + + add(ExternalApplicationId::netbeans, "NetBeans", firstExisting({ + under(program_files, "NetBeans-25/netbeans/bin/netbeans64.exe"), + under(program_files, "NetBeans-24/netbeans/bin/netbeans64.exe"), + under(program_files, "NetBeans-23/netbeans/bin/netbeans64.exe"), + under(program_files, "NetBeans/netbeans/bin/netbeans64.exe"), + under(program_files_x86, "NetBeans/netbeans/bin/netbeans.exe"), + })); + + // ------------------------------------------------------------------------- + // Terminal editors + // ------------------------------------------------------------------------- + + add(ExternalApplicationId::vim, "Vim", firstExisting({ + under(program_files, "Vim/vim91/gvim.exe"), + under(program_files, "Vim/vim90/gvim.exe"), + under(program_files_x86, "Vim/vim91/gvim.exe"), + under(program_files_x86, "Vim/vim90/gvim.exe"), + under(user_profile, "scoop/apps/vim/current/gvim.exe"), + })); + + add(ExternalApplicationId::neovim, "Neovim", firstExisting({ + under(program_files, "Neovim/bin/nvim-qt.exe"), + under(program_files, "Neovim/bin/nvim.exe"), + under(local, "Programs/Neovim/bin/nvim-qt.exe"), + under(local, "Programs/Neovim/bin/nvim.exe"), + under(user_profile, "scoop/apps/neovim/current/bin/nvim.exe"), + })); + + add(ExternalApplicationId::emacs, "Emacs", firstExisting({ + under(program_files, "Emacs/x86_64/bin/runemacs.exe"), + under(program_files, "Emacs/bin/runemacs.exe"), + under(program_files_x86, "Emacs/bin/runemacs.exe"), + under(user_profile, "scoop/apps/emacs/current/bin/runemacs.exe"), + })); + + add(ExternalApplicationId::helix, "Helix", firstExisting({ + under(program_files, "helix/hx.exe"), + under(program_files_x86, "helix/hx.exe"), + under(user_profile, "scoop/apps/helix/current/hx.exe"), + })); } 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 application.id == id; + }); + return found == applications_.end() ? nullptr : &*found; } ExternalApplicationId ApplicationManager::defaultApplication() const { + const auto preferred = std::find_if(targets_.begin(), targets_.end(), + [](const LaunchTarget &target) + { + return target.info.available && + target.info.id != ExternalApplicationId::file_explorer && + target.info.id != ExternalApplicationId::terminal && + target.info.id != ExternalApplicationId::git_bash && + target.info.id != ExternalApplicationId::wsl; + }); + + if (preferred != targets_.end()) + return preferred->info.id; + const auto available = std::find_if(targets_.begin(), targets_.end(), [](const LaunchTarget &target) - { return target.info.available; }); + { + return target.info.available; + }); + return available == targets_.end() ? 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; }); + { + return target.info.id == application; + }); + if (found == targets_.end() || !found->info.available) { error = "The selected application is not installed"; return false; } + std::vector arguments = found->arguments; const std::string repository_path = repository.string(); + if (application == ExternalApplicationId::git_bash && !arguments.empty()) + { arguments.back() += repository_path; + } else + { arguments.push_back(repository_path); + } + const izo::ProcessResult result = izo::LaunchProcess({ found->info.executable, std::move(arguments), repository, true, }); + if (!result) { error = result.errorMessage.empty() ? "Unable to launch application" : result.errorMessage; return false; } + error = "Opened repository in " + found->info.name; return true; -} +} \ No newline at end of file diff --git a/src/managers/application_manager.h b/src/managers/application_manager.h index 5c8c9ef..18401fb 100644 --- a/src/managers/application_manager.h +++ b/src/managers/application_manager.h @@ -7,15 +7,55 @@ enum class ExternalApplicationId { visual_studio_code, - visual_studio, + visual_studio_code_insiders, + vscodium, + cursor, + windsurf, + trae, + zed, antigravity, + + visual_studio, + github_desktop, file_explorer, terminal, git_bash, wsl, + android_studio, + intellij_idea, + clion, + rider, + pycharm, + webstorm, + phpstorm, + rubymine, + goland, + datagrip, + jetbrains_fleet, + + sublime_text, + notepad_plus_plus, + atom, + pulsar, + brackets, + lapce, + lite_xl, + geany, + kate, + + qt_creator, + codeblocks, + dev_cpp, + eclipse, + netbeans, + + vim, + neovim, + emacs, + helix, }; struct ExternalApplication @@ -46,4 +86,4 @@ private: std::vector targets_; std::vector applications_; -}; +}; \ No newline at end of file diff --git a/src/managers/git_manager.cpp b/src/managers/git_manager.cpp index 5a8c31d..b601a73 100644 --- a/src/managers/git_manager.cpp +++ b/src/managers/git_manager.cpp @@ -80,6 +80,25 @@ namespace return action; } + void populateRepositoryIdentity(RepositoryView &repository) + { + 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(); + + repository.branch = "detached"; + 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); + } + } + void addBadge(RepositoryView &repository, const git_oid *oid, RefBadge badge) { if (!oid) @@ -369,54 +388,65 @@ void GitManager::computeGraphLanes(RepositoryView &repository) { git_oid expected{}; }; + + const auto lane_for_commit = [](std::vector &lanes, const git_oid &oid) { + const auto found = std::find_if(lanes.begin(), lanes.end(), [&oid](const Lane &lane) + { return git_oid_equal(&lane.expected, &oid) != 0; }); + if (found != lanes.end()) + return static_cast(std::distance(lanes.begin(), found)); + lanes.push_back({oid}); + return lanes.size() - 1; + }; + + const auto contains_oid = [](const std::vector &lanes, const git_oid &oid) { + return std::any_of(lanes.begin(), lanes.end(), [&oid](const Lane &lane) + { return git_oid_equal(&lane.expected, &oid) != 0; }); + }; + 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()); - } - const size_t commit_lane = static_cast(std::distance(lanes.begin(), current)); - size_t active_lane = commit_lane; + size_t active_lane = lane_for_commit(lanes, commit.oid); + const size_t commit_lane = active_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(); + + size_t insert_lane = active_lane + 1; + for (size_t parent = 1; parent < commit.parent_ids.size(); ++parent) + { + if (contains_oid(lanes, commit.parent_ids[parent])) + continue; + lanes.insert(lanes.begin() + static_cast(std::min(insert_lane, lanes.size())), + {commit.parent_ids[parent]}); + ++insert_lane; + } + for (size_t duplicate = lanes.size(); duplicate-- > 0;) { - if (duplicate != active_lane && - git_oid_equal(&lanes[duplicate].expected, &commit.parent_ids.front()) != 0) + if (duplicate == active_lane) + continue; + if (git_oid_equal(&lanes[duplicate].expected, &commit.oid) != 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) + + for (size_t left = 0; left < lanes.size(); ++left) { - 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( - std::min(active_lane + parent, lanes.size())), - {commit.parent_ids[parent]}); + for (size_t right = lanes.size(); right-- > left + 1;) + { + if (git_oid_equal(&lanes[left].expected, &lanes[right].expected) != 0) + lanes.erase(lanes.begin() + static_cast(right)); + } } } } @@ -528,26 +558,15 @@ bool GitManager::loadRepositoryData(RepositoryView &repository, std::string &err repository.commits.clear(); repository.selected_commit = 0; repository.scroll_to_commit = -1; + repository.undo_action = {}; + repository.redo_action = {}; 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); - 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(); + populateRepositoryIdentity(repository); 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_free(head); - } readBranches(repository, GIT_BRANCH_LOCAL, repository.local_branches); readBranches(repository, GIT_BRANCH_REMOTE, repository.remote_branches); loadBranchDivergence(repository); @@ -672,7 +691,7 @@ bool GitManager::loadMoreCommits(RepositoryView &repository, size_t page_size, s return true; } -bool GitManager::openRepository(RepositoryView &repository, const std::string &path, std::string &error) +bool GitManager::openRepositoryHandle(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) @@ -682,6 +701,14 @@ bool GitManager::openRepository(RepositoryView &repository, const std::string &p } repository.close(); repository.repo = opened; + populateRepositoryIdentity(repository); + return true; +} + +bool GitManager::openRepository(RepositoryView &repository, const std::string &path, std::string &error) +{ + if (!openRepositoryHandle(repository, path, error)) + return false; return loadRepositoryData(repository, error); } @@ -1250,6 +1277,27 @@ bool GitManager::checkoutBranch(RepositoryView &repository, const std::string &b return true; } +bool GitManager::mergeBranch(RepositoryView &repository, const std::string &source_branch, + const std::string &target_branch, std::string &error) +{ + if (source_branch.empty() || target_branch.empty()) + { + error = "Unable to merge branches"; + return false; + } + if (source_branch == target_branch) + { + error = "A branch cannot be merged into itself"; + return false; + } + if (repository.branch != target_branch && !checkoutBranch(repository, target_branch, error)) + return false; + if (!runGit(repository, {"merge", "--no-ff", "--no-edit", source_branch}, "Branches merged", error)) + return false; + error = "Merged " + source_branch + " into " + target_branch; + return true; +} + bool GitManager::updateSubmodule(RepositoryView &repository, const std::string &name, std::string &error) { git_submodule *submodule = nullptr; diff --git a/src/managers/git_manager.h b/src/managers/git_manager.h index 8bb4fcf..fd08dac 100644 --- a/src/managers/git_manager.h +++ b/src/managers/git_manager.h @@ -1,69 +1,72 @@ -#pragma once - -#include "models/repository.h" -#include -#include - -class GitManager -{ -public: +#pragma once + +#include "models/repository.h" +#include +#include + +class GitManager +{ +public: GitManager(); ~GitManager(); + bool openRepositoryHandle(RepositoryView &repository, const std::string &path, 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 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 mergeBranch(RepositoryView &repository, const std::string &source_branch, + const std::string &target_branch, std::string &error); bool fetch(RepositoryView &repository, const std::string &remote, std::string &error); - bool pull(RepositoryView &repository, int mode, std::string &error); - bool push(RepositoryView &repository, std::string &error); - bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error); - bool stash(RepositoryView &repository, std::string &error); - bool popStash(RepositoryView &repository, std::string &error); - bool 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 formatTime(git_time_t value, int offset_minutes); - 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); -}; + 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 formatTime(git_time_t value, int offset_minutes); + 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 94e1d97..ebff15f 100644 --- a/src/managers/user_data.cpp +++ b/src/managers/user_data.cpp @@ -65,6 +65,8 @@ void UserData::load() sidebar_width_ = static_cast(ikv_as_float(value)); if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT)) details_width_ = static_cast(ikv_as_float(value)); + if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL)) + sidebar_collapsed_ = ikv_as_bool(value) != 0; if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT)) pull_mode_ = static_cast(ikv_as_int(value)); if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT)) @@ -76,6 +78,20 @@ void UserData::load() for (uint32_t index = 0; index < count; ++index) sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); } + if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY)) + { + const uint32_t count = std::min( + ikv_array_size(open), static_cast(sidebar_section_open_.size())); + for (uint32_t index = 0; index < count; ++index) + sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0; + } + if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY)) + { + const uint32_t count = std::min( + ikv_array_size(widths), static_cast(commit_table_column_widths_.size())); + for (uint32_t index = 0; index < count; ++index) + commit_table_column_widths_[index] = static_cast(ikv_as_float(ikv_array_get(widths, index))); + } } if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) { @@ -117,6 +133,8 @@ void UserData::load() zoom_percent_ = std::clamp(zoom_percent_, 80, 200); for (float &height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f); + for (float &width : commit_table_column_widths_) + width = std::clamp(width, 0.0f, 1200.0f); if (open_repositories_.empty()) active_repository_ = 0; else @@ -133,6 +151,8 @@ void UserData::load() sidebar_width_ = static_cast(ikv_as_float(value)); if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT)) details_width_ = static_cast(ikv_as_float(value)); + if (const ikv_node_t *value = object_value(settings, "sidebar_collapsed", IKV_BOOL)) + sidebar_collapsed_ = ikv_as_bool(value) != 0; if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT)) pull_mode_ = static_cast(ikv_as_int(value)); if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT)) @@ -144,6 +164,20 @@ void UserData::load() for (uint32_t index = 0; index < count; ++index) sidebar_section_heights_[index] = static_cast(ikv_as_float(ikv_array_get(heights, index))); } + if (const ikv_node_t *open = object_value(settings, "sidebar_section_open", IKV_ARRAY)) + { + const uint32_t count = std::min( + ikv_array_size(open), static_cast(sidebar_section_open_.size())); + for (uint32_t index = 0; index < count; ++index) + sidebar_section_open_[index] = ikv_as_bool(ikv_array_get(open, index)) != 0; + } + if (const ikv_node_t *widths = object_value(settings, "commit_table_columns", IKV_ARRAY)) + { + const uint32_t count = std::min( + ikv_array_size(widths), static_cast(commit_table_column_widths_.size())); + for (uint32_t index = 0; index < count; ++index) + commit_table_column_widths_[index] = static_cast(ikv_as_float(ikv_array_get(widths, index))); + } } if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY)) { @@ -185,6 +219,8 @@ void UserData::load() zoom_percent_ = std::clamp(zoom_percent_, 80, 200); for (float &height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f); + for (float &width : commit_table_column_widths_) + width = std::clamp(width, 0.0f, 1200.0f); if (open_repositories_.empty()) active_repository_ = 0; else @@ -299,11 +335,18 @@ void UserData::save() const ikv_node_t *settings = ikv_object_add_object(root, "settings"); ikv_object_set_float(settings, "sidebar_width", sidebar_width_); ikv_object_set_float(settings, "details_width", details_width_); + ikv_object_set_bool(settings, "sidebar_collapsed", sidebar_collapsed_); ikv_object_set_int(settings, "pull_mode", pull_mode_); ikv_object_set_int(settings, "zoom_percent", zoom_percent_); ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT); for (const float height : sidebar_section_heights_) ikv_array_add_float(heights, height); + ikv_node_t *open = ikv_object_add_array(settings, "sidebar_section_open", IKV_BOOL); + for (const bool state : sidebar_section_open_) + ikv_array_add_bool(open, state); + ikv_node_t *widths = ikv_object_add_array(settings, "commit_table_columns", IKV_FLOAT); + for (const float width : commit_table_column_widths_) + ikv_array_add_float(widths, width); ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING); for (const auto &path : recently_closed_) diff --git a/src/managers/user_data.h b/src/managers/user_data.h index 8826b8c..d74b27f 100644 --- a/src/managers/user_data.h +++ b/src/managers/user_data.h @@ -19,12 +19,18 @@ public: [[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]] bool sidebarCollapsed() const { return sidebar_collapsed_; } + [[nodiscard]] bool sidebarSectionOpen(size_t index) const { return sidebar_section_open_.at(index); } + [[nodiscard]] float commitTableColumnWidth(size_t index) const { return commit_table_column_widths_.at(index); } [[nodiscard]] int pullMode() const { return pull_mode_; } [[nodiscard]] int zoomPercent() const { return zoom_percent_; } 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 setSidebarCollapsed(bool collapsed) { sidebar_collapsed_ = collapsed; } + void setSidebarSectionOpen(size_t index, bool open) { sidebar_section_open_.at(index) = open; } + void setCommitTableColumnWidth(size_t index, float width) { commit_table_column_widths_.at(index) = width; } void setPullMode(int mode) { pull_mode_ = mode; @@ -54,6 +60,9 @@ private: float sidebar_width_ = 230.0f; float details_width_ = 368.0f; std::array sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f}; + bool sidebar_collapsed_ = false; + std::array sidebar_section_open_ = {true, true, true, true}; + std::array commit_table_column_widths_ = {0.0f, 0.0f, 0.0f, 0.0f}; int pull_mode_ = 1; int zoom_percent_ = 100; }; diff --git a/src/models/repository.h b/src/models/repository.h index 476e38d..db5a078 100644 --- a/src/models/repository.h +++ b/src/models/repository.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -85,6 +86,8 @@ struct RepositoryView { std::vector working_files; int selected_commit = 0; int scroll_to_commit = -1; + std::chrono::steady_clock::time_point last_background_refresh{}; + bool pending_background_refresh = false; ToolbarHistoryAction undo_action; ToolbarHistoryAction redo_action; diff --git a/src/ui/diff_viewer.cpp b/src/ui/diff_viewer.cpp index 3409d79..930a772 100644 --- a/src/ui/diff_viewer.cpp +++ b/src/ui/diff_viewer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -152,23 +153,23 @@ bool isMacroName(std::string_view word) { return has_letter && word.size() > 1; } +constexpr ImU32 syntax_normal = IM_COL32(218, 221, 226, 255); +constexpr ImU32 syntax_keyword = IM_COL32(198, 139, 230, 255); +constexpr ImU32 syntax_type = IM_COL32(91, 198, 190, 255); +constexpr ImU32 syntax_string = IM_COL32(226, 166, 140, 255); +constexpr ImU32 syntax_number = IM_COL32(181, 206, 126, 255); +constexpr ImU32 syntax_comment = IM_COL32(112, 153, 105, 255); +constexpr ImU32 syntax_function = IM_COL32(220, 199, 128, 255); +constexpr ImU32 syntax_preprocessor = IM_COL32(205, 157, 222, 255); +constexpr ImU32 syntax_member = IM_COL32(122, 184, 225, 255); +constexpr ImU32 syntax_builtin = IM_COL32(103, 172, 232, 255); +constexpr ImU32 syntax_constant = IM_COL32(214, 139, 102, 255); +constexpr ImU32 syntax_operator = IM_COL32(105, 180, 210, 255); +constexpr ImU32 syntax_decorator = IM_COL32(226, 190, 105, 255); +constexpr ImU32 syntax_macro = IM_COL32(215, 128, 180, 255); + [[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; @@ -177,15 +178,15 @@ bool isMacroName(std::string_view word) { position.x += ImGui::CalcTextSize(first, last, false).x; }; if (language == SyntaxLanguage::plain) { - drawSpan(0, text.size(), normal); + drawSpan(0, text.size(), syntax_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); + drawSpan(0, first_non_space, syntax_normal); + drawSpan(first_non_space, text.size(), syntax_preprocessor); return; } @@ -194,16 +195,16 @@ bool isMacroName(std::string_view 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); + if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_comment); return; } + drawSpan(cursor, end + 2, syntax_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); + if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_comment); return; } + drawSpan(cursor, end + 2, syntax_comment); cursor = end + 2; state.continuation = SyntaxContinuation::none; continue; @@ -212,16 +213,16 @@ bool isMacroName(std::string_view word) { 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); + if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_string); return; } + drawSpan(cursor, end + delimiter.size(), syntax_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); + if (end == std::string::npos) { drawSpan(cursor, text.size(), syntax_string); return; } + drawSpan(cursor, end + 1, syntax_string); cursor = end + 1; state.continuation = SyntaxContinuation::none; continue; @@ -230,37 +231,37 @@ bool isMacroName(std::string_view word) { 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); + drawSpan(cursor, text.size(), syntax_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); + drawSpan(cursor, text.size(), syntax_comment); state.continuation = SyntaxContinuation::block_comment; return; } - drawSpan(cursor, end + 2, comment); + drawSpan(cursor, end + 2, syntax_comment); cursor = end + 2; continue; } if (language == SyntaxLanguage::python && text[cursor] == '#') { - drawSpan(cursor, text.size(), comment); + drawSpan(cursor, text.size(), syntax_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); + drawSpan(cursor, text.size(), syntax_comment); state.continuation = SyntaxContinuation::lua_comment; return; } - drawSpan(cursor, end + 2, comment); + drawSpan(cursor, end + 2, syntax_comment); cursor = end + 2; continue; } if (language == SyntaxLanguage::lua && text.compare(cursor, 2, "--") == 0) { - drawSpan(cursor, text.size(), comment); + drawSpan(cursor, text.size(), syntax_comment); return; } if (language == SyntaxLanguage::python && @@ -268,11 +269,11 @@ bool isMacroName(std::string_view word) { 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); + drawSpan(cursor, text.size(), syntax_string); state.continuation = delimiter[0] == '\'' ? SyntaxContinuation::python_single : SyntaxContinuation::python_double; return; } - drawSpan(cursor, end + 3, string); + drawSpan(cursor, end + 3, syntax_string); cursor = end + 3; continue; } @@ -287,7 +288,7 @@ bool isMacroName(std::string_view word) { if (text[end] != '\\') escaped = false; } const bool closed = end <= text.size() && end > cursor + 1 && text[end - 1] == quote; - drawSpan(cursor, end, string); + drawSpan(cursor, end, syntax_string); if (quote == '`' && !closed) state.continuation = SyntaxContinuation::template_string; cursor = end; continue; @@ -296,7 +297,7 @@ bool isMacroName(std::string_view word) { size_t end = cursor + 1; while (end < text.size() && (std::isalnum(static_cast(text[end])) || text[end] == '.' || text[end] == '_')) ++end; - drawSpan(cursor, end, number); + drawSpan(cursor, end, syntax_number); cursor = end; continue; } @@ -305,7 +306,7 @@ bool isMacroName(std::string_view word) { size_t end = cursor + 2; while (end < text.size() && (std::isalnum(static_cast(text[end])) || text[end] == '_' || text[end] == '.')) ++end; - drawSpan(cursor, end, decorator); + drawSpan(cursor, end, syntax_decorator); cursor = end; continue; } @@ -328,11 +329,11 @@ bool isMacroName(std::string_view word) { (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; + const ImU32 color = isLiteral(word) ? syntax_constant : isKeyword(language, word) ? syntax_keyword : + declared_function ? syntax_function : declared_name || likely_type ? syntax_type : + isMacroName(word) ? syntax_macro : isBuiltin(language, word) ? syntax_builtin : member_access ? + (next < text.size() && text[next] == '(' ? syntax_function : syntax_member) : + next < text.size() && text[next] == '(' ? syntax_function : syntax_normal; drawSpan(cursor, end, color); previous_word = word; cursor = end; @@ -342,7 +343,7 @@ bool isMacroName(std::string_view word) { 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); + drawSpan(cursor, end, syntax_operator); cursor = end; continue; } @@ -357,7 +358,7 @@ bool isMacroName(std::string_view word) { if (token_start) break; ++end; } - drawSpan(cursor, end, normal); + drawSpan(cursor, end, syntax_normal); cursor = end; } } @@ -372,58 +373,189 @@ bool compactButton(const char* label, bool active = false) { 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]; +bool toolbarSegmentButton(const char* label, bool active, float width) { + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0.0f); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.15f, 0.29f, 0.49f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.12f, 0.22f, 0.39f, 1.0f)); } - return text; + const bool clicked = ImGui::Button(label, {width, 0.0f}); + if (active) ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + return clicked; } -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; +int wrappedLineCount(std::string_view text, int wrap_columns) { + if (wrap_columns <= 0 || static_cast(text.size()) <= wrap_columns) return 1; + int lines = 0; + size_t cursor = 0; + while (cursor < text.size()) { + size_t remaining = text.size() - cursor; + if (remaining <= static_cast(wrap_columns)) { + ++lines; + break; + } + size_t split = cursor + static_cast(wrap_columns); + size_t break_at = text.rfind(' ', split); + if (break_at == std::string_view::npos || break_at < cursor + static_cast(wrap_columns / 3)) + break_at = split; + cursor = break_at; + while (cursor < text.size() && text[cursor] == ' ') ++cursor; + ++lines; + } + return std::max(lines, 1); } -struct MinimapEntry { - size_t length = 0; - ImU32 color = IM_COL32(120, 126, 136, 255); +struct MinimapSegment { + float weight = 0.0f; + ImU32 color = syntax_normal; }; -void drawMinimap(const std::vector& entries, float scale, - float rendered_content_height, float visible_start, float visible_height) { +struct MinimapEntry { + std::vector segments; + float units = 1.0f; +}; + +void addMinimapSegment(std::vector& segments, float weight, ImU32 color) { + if (weight <= 0.0f) return; + if (!segments.empty() && segments.back().color == color) { + segments.back().weight += weight; + return; + } + segments.push_back({weight, color}); +} + +std::vector buildMinimapSegments(const std::string& text, SyntaxLanguage language) { + std::vector segments; + if (text.empty()) { + segments.push_back({0.10f, IM_COL32(0, 0, 0, 0)}); + return segments; + } + size_t cursor = 0; + bool seen_identifier = false; + while (cursor < text.size()) { + const unsigned char value = static_cast(text[cursor]); + if (std::isspace(value)) { + size_t end = cursor + 1; + while (end < text.size() && std::isspace(static_cast(text[end]))) ++end; + addMinimapSegment(segments, std::max(0.04f, static_cast(end - cursor) * 0.18f), IM_COL32(0, 0, 0, 0)); + cursor = end; + continue; + } + + if ((language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) && + cursor + 1 < text.size() && text[cursor] == '/' && text[cursor + 1] == '/') { + addMinimapSegment(segments, std::max(0.18f, static_cast(text.size() - cursor) * 0.42f), syntax_comment); + break; + } + if (language == SyntaxLanguage::python && text[cursor] == '#') { + addMinimapSegment(segments, std::max(0.18f, static_cast(text.size() - cursor) * 0.42f), syntax_comment); + break; + } + if (language == SyntaxLanguage::lua && cursor + 1 < text.size() && text[cursor] == '-' && text[cursor + 1] == '-') { + addMinimapSegment(segments, std::max(0.18f, static_cast(text.size() - cursor) * 0.42f), syntax_comment); + break; + } + + if (text[cursor] == '"' || text[cursor] == '\'' || (language == SyntaxLanguage::javascript && text[cursor] == '`')) { + const char delimiter = text[cursor]; + size_t end = cursor + 1; + while (end < text.size()) { + if (text[end] == delimiter && text[end - 1] != '\\') { + ++end; + break; + } + ++end; + } + addMinimapSegment(segments, std::max(0.10f, static_cast(end - cursor) * 0.34f), syntax_string); + cursor = end; + continue; + } + + if (std::isdigit(value)) { + size_t end = cursor + 1; + while (end < text.size() && (std::isalnum(static_cast(text[end])) || text[end] == '.' || text[end] == '_')) ++end; + addMinimapSegment(segments, std::max(0.08f, static_cast(end - cursor) * 0.28f), syntax_number); + cursor = end; + continue; + } + + if (std::isalpha(value) || 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); + ImU32 color = syntax_normal; + if (isKeyword(language, word)) color = syntax_keyword; + else if (isTypeWord(language, word)) color = syntax_type; + else if (isBuiltin(language, word)) color = syntax_builtin; + else if (isLiteral(word)) color = syntax_constant; + else if (word.front() == '@') color = syntax_decorator; + else if (isMacroName(word)) color = syntax_macro; + else if (!seen_identifier && (isDeclarationKeyword(word) || isFunctionDeclarationKeyword(word))) + color = syntax_keyword; + else if (!seen_identifier && cursor > 0 && text[cursor - 1] == '.') color = syntax_member; + else if (end < text.size() && text[end] == '(') color = syntax_function; + addMinimapSegment(segments, std::max(0.08f, static_cast(end - cursor) * 0.26f), color); + seen_identifier = true; + 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; + const ImU32 color = text[cursor] == '#' ? syntax_preprocessor : syntax_operator; + addMinimapSegment(segments, std::max(0.06f, static_cast(end - cursor) * 0.22f), color); + cursor = end; + continue; + } + + addMinimapSegment(segments, 0.05f, syntax_normal); + ++cursor; + } + if (segments.empty()) segments.push_back({0.10f, syntax_normal}); + return segments; +} + +void drawMinimap(const std::vector& entries, const ImVec2& size, float scale, + float rendered_content_height, float visible_start, float visible_height, float max_scroll_y) { const ImVec2 minimum = ImGui::GetCursorScreenPos(); - const ImVec2 size = ImGui::GetContentRegionAvail(); ImGui::InvisibleButton("##code_minimap", size); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); ImDrawList* draw = ImGui::GetWindowDrawList(); - draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(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)); + draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(31, 33, 39, 235), scaled(4.0f, scale)); + draw->AddRect(minimum, {minimum.x + size.x, minimum.y + size.y}, + hovered || active ? IM_COL32(82, 90, 103, 255) : IM_COL32(56, 61, 71, 255), scaled(4.0f, scale)); 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_left = minimum.x + scaled(5.0f, scale); + const float content_right = minimum.x + size.x - scaled(5.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)); + float total_units = 0.0f; + for (const MinimapEntry& entry : entries) total_units += std::max(0.10f, entry.units); + total_units = std::max(1.0f, total_units); + const float unit_height = size.y / total_units; + const float line_height = std::max(1.0f, std::min(scaled(3.2f, scale), unit_height)); + float y = minimum.y; + for (const MinimapEntry& entry : entries) { + const float entry_units = std::max(0.10f, entry.units); + const float total_weight = std::max(0.10f, std::accumulate(entry.segments.begin(), entry.segments.end(), 0.0f, + [](float sum, const MinimapSegment& segment) { return sum + std::max(0.0f, segment.weight); })); + float x = content_left; + for (const MinimapSegment& segment : entry.segments) { + if (segment.color == IM_COL32(0, 0, 0, 0)) { + x += content_width * (segment.weight / total_weight); + continue; + } + const float width = std::max(scaled(1.0f, scale), content_width * (segment.weight / total_weight)); + draw->AddRectFilled({x, y}, {std::min(content_right, x + width), y + line_height}, + segment.color, scaled(1.0f, scale)); + x += width; + } + y += entry_units * unit_height; } const float content_height = std::max(rendered_content_height, 1.0f); @@ -432,27 +564,58 @@ void drawMinimap(const std::vector& entries, float scale, 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); + if ((hovered || active) && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + const float mouse_ratio = std::clamp((ImGui::GetIO().MousePos.y - minimum.y) / std::max(1.0f, size.y), 0.0f, 1.0f); + const float target = std::clamp(mouse_ratio * content_height - clamped_visible_height * 0.5f, 0.0f, max_scroll_y); + ImGui::SetScrollY(target); + } 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)); + {minimum.x + size.x - scaled(1.0f, scale), viewport_y + viewport_height}, + IM_COL32(120, 146, 198, hovered || active ? 62 : 42), scaled(2.0f, scale)); 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)); + {minimum.x + size.x - scaled(1.0f, scale), viewport_y + viewport_height}, + IM_COL32(120, 146, 198, 185), 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); + float minimum_width = 0.0f, float custom_row_height = 0.0f, int wrap_columns = 0) { + const float base_row_height = scaled(21.0f, scale); + const int visual_lines = wrappedLineCount(text, wrap_columns); + const float row_height = custom_row_height > 0.0f ? custom_row_height : base_row_height * static_cast(visual_lines); const ImVec2 start = ImGui::GetCursorScreenPos(); - const float width = std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale)); + const float width = minimum_width > 0.0f ? minimum_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); + if (wrap_columns <= 0 || static_cast(text.size()) <= wrap_columns) { + drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale)}, text, language, syntax); + return; + } + + size_t cursor = 0; + int line_index = 0; + while (cursor < text.size()) { + size_t remaining = text.size() - cursor; + size_t chunk_end = text.size(); + if (remaining > static_cast(wrap_columns)) { + chunk_end = cursor + static_cast(wrap_columns); + size_t break_at = text.rfind(' ', chunk_end); + if (break_at == std::string::npos || break_at < cursor + static_cast(wrap_columns / 3)) + break_at = chunk_end; + chunk_end = break_at; + } + const std::string chunk = text.substr(cursor, chunk_end - cursor); + drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale) + base_row_height * static_cast(line_index)}, + chunk, language, syntax); + cursor = chunk_end; + while (cursor < text.size() && text[cursor] == ' ') ++cursor; + ++line_index; + } } void drawCodeLineNumber(int value, float x, float y, ImU32 color) { @@ -460,6 +623,32 @@ void drawCodeLineNumber(int value, float x, float y, ImU32 color) { const std::string text = std::to_string(value); ImGui::GetWindowDrawList()->AddText({x, y}, color, text.c_str()); } + +std::string ellipsizeText(std::string_view text, float max_width) { + if (text.empty() || max_width <= 0.0f) return {}; + + std::string display{text}; + if (ImGui::CalcTextSize(display.c_str()).x <= max_width) return display; + + constexpr const char* overflow = "..."; + const float overflow_width = ImGui::CalcTextSize(overflow).x; + if (overflow_width >= max_width) return overflow; + + while (!display.empty() && ImGui::CalcTextSize(display.c_str()).x + overflow_width > max_width) { + display.pop_back(); + } + display += overflow; + return display; +} + +void drawEllipsizedText(ImDrawList* draw, ImVec2 position, ImU32 color, std::string_view text, + float max_width, const ImVec2& clip_min, const ImVec2& clip_max) { + const std::string display = ellipsizeText(text, max_width); + if (display.empty()) return; + draw->PushClipRect(clip_min, clip_max, true); + draw->AddText(position, color, display.c_str()); + draw->PopClipRect(); +} } void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path, @@ -468,6 +657,7 @@ void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std commit_id_.clear(); staged_ = staged; mode_ = Mode::diff; + scroll_to_top_ = true; reload(repository, manager, notice); } @@ -477,10 +667,12 @@ void DiffViewer::openCommit(RepositoryView& repository, GitManager& manager, commit_id_ = commit_id; staged_ = false; mode_ = Mode::diff; + scroll_to_top_ = true; reload(repository, manager, notice); if (hunks_.empty()) { mode_ = Mode::file; loadSupplement(repository, manager, mode_, notice); + scroll_to_top_ = true; } } @@ -491,7 +683,7 @@ void DiffViewer::close() { hunks_.clear(); file_lines_.clear(); blame_lines_.clear(); - history_lines_.clear(); + history_entries_.clear(); } void DiffViewer::parseDiff(const std::string& text) { @@ -583,6 +775,30 @@ void DiffViewer::parseBlame(const std::string& text) { } } +void DiffViewer::parseHistory(const std::string& text) { + history_entries_.clear(); + for (const std::string& line : splitLines(text)) { + if (line.empty()) continue; + HistoryEntry entry; + size_t cursor = 0; + auto consume_field = [&](std::string& out) { + const size_t separator = line.find('\x1f', cursor); + if (separator == std::string::npos) { + out = line.substr(cursor); + cursor = line.size(); + return; + } + out = line.substr(cursor, separator - cursor); + cursor = separator + 1; + }; + consume_field(entry.hash); + consume_field(entry.date); + consume_field(entry.author); + consume_field(entry.summary); + if (!entry.hash.empty() || !entry.summary.empty()) history_entries_.push_back(std::move(entry)); + } +} + void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::string& notice) { std::vector arguments; if (commit_id_.empty()) { @@ -620,7 +836,7 @@ void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::st } file_lines_.clear(); blame_lines_.clear(); - history_lines_.clear(); + history_entries_.clear(); if (mode_ != Mode::diff) loadSupplement(repository, manager, mode_, notice); } @@ -649,108 +865,122 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager, parseBlame(output); } else if (mode == Mode::history) { std::vector arguments{ - "log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s"}; + "log", "--follow", "--date=short", "--pretty=format:%h%x1f%ad%x1f%an%x1f%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); + parseHistory(output); } } 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_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))); 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); - } - ImGui::SameLine(); - if (compactButton("Diff View", mode_ == Mode::diff)) mode_ = Mode::diff; - ImGui::SameLine(0, scaled(28, scale)); - if (compactButton("Blame", mode_ == Mode::blame)) { - mode_ = Mode::blame; loadSupplement(repository, manager, mode_, notice); - } - ImGui::SameLine(); - if (compactButton("History", mode_ == Mode::history)) { - mode_ = Mode::history; loadSupplement(repository, manager, mode_, 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::SameLine(); + const float top_right_width = scaled(72.0f, scale); + ImGui::SameLine(std::max(scaled(160.0f, scale), ImGui::GetWindowWidth() - top_right_width)); + ImGui::TextDisabled("UTF-8"); + ImGui::SameLine(0, scaled(10.0f, scale)); if (compactButton(ICON_TB_XMARK)) close(); ImGui::Separator(); - if (!path_.empty()) { - ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale)); - ImGui::TextDisabled("UTF-8"); - if (!historical && !staged_) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1)); - if (compactButton(ICON_TB_TRASH_CAN)) { - if (manager.discardFile(repository, path_, notice)) close(); - } - ImGui::PopStyleColor(); - } - ImGui::Separator(); - } - const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file; - const float minimap_width = scaled(56.0f, scale); + const float segment_width = scaled(86.0f, scale); + const float file_group_width = segment_width * 2.0f; + const float blame_group_width = segment_width * 2.0f; + const float wrap_width = scaled(34.0f, scale); + const float between_groups = scaled(22.0f, scale); + const float wrap_gap = scaled(12.0f, scale); + const float toolbar_width = file_group_width + between_groups + blame_group_width + wrap_gap + wrap_width; + ImGui::SetCursorPosX(std::max(0.0f, (ImGui::GetWindowWidth() - toolbar_width) * 0.5f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {0.0f, ImGui::GetStyle().ItemSpacing.y}); + if (toolbarSegmentButton("File View", mode_ == Mode::file, segment_width)) { + mode_ = Mode::file; + loadSupplement(repository, manager, mode_, notice); + scroll_to_top_ = true; + } + ImGui::SameLine(); + if (toolbarSegmentButton("Diff View", mode_ == Mode::diff, segment_width)) { + mode_ = Mode::diff; + scroll_to_top_ = true; + } + ImGui::SameLine(0, between_groups); + if (toolbarSegmentButton("Blame", mode_ == Mode::blame, segment_width)) { + mode_ = Mode::blame; + loadSupplement(repository, manager, mode_, notice); + } + ImGui::SameLine(); + if (toolbarSegmentButton("History", mode_ == Mode::history, segment_width)) { + mode_ = Mode::history; + loadSupplement(repository, manager, mode_, notice); + } + ImGui::PopStyleVar(); + ImGui::SameLine(0, wrap_gap); + toolbarSegmentButton(ICON_TB_BARS, line_wrap_, wrap_width); + if (ImGui::IsItemClicked()) line_wrap_ = !line_wrap_; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) + ImGui::SetTooltip("%s", line_wrap_ ? "Disable line wrap" : "Enable line wrap"); + ImGui::Separator(); + + const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file || mode_ == Mode::blame || mode_ == Mode::history; + const float minimap_width = scaled(168.0f, scale); + const float minimap_gap = scaled(10.0f, scale); + const float scrollbar_width = ImGui::GetStyle().ScrollbarSize; + const SyntaxLanguage language = languageForPath(path_); 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)}); + minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.65f}); 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({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.55f}); + minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.45f}); } - 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({buildMinimapSegments(line.text, language), + line.kind == LineKind::context ? 1.0f : 1.0f}); } - minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)}); + minimap_entries.push_back({{{0.10f, IM_COL32(0, 0, 0, 0)}}, 0.30f}); } - } else { + } else if (mode_ == Mode::file) { 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)}); + minimap_entries.push_back({buildMinimapSegments(line, language), 1.0f}); + } else if (mode_ == Mode::blame) { + minimap_entries.reserve(blame_lines_.size()); + for (const BlameLine& line : blame_lines_) + minimap_entries.push_back({buildMinimapSegments(line.text, language), line.show_attribution ? 1.35f : 1.0f}); + } else { + minimap_entries.reserve(history_entries_.size()); + for (const HistoryEntry& entry : history_entries_) + minimap_entries.push_back({buildMinimapSegments(entry.summary, SyntaxLanguage::plain), 1.7f}); } } - ImGui::BeginChild("diff_content_main", - show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1}, - ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + ImGuiWindowFlags content_flags = line_wrap_ ? ImGuiWindowFlags_None : ImGuiWindowFlags_HorizontalScrollbar; + if (scroll_to_top_) ImGui::SetNextWindowScroll({0.0f, 0.0f}); + ImGui::BeginChild("diff_content_main", ImVec2{-1, -1}, ImGuiChildFlags_None, content_flags); + scroll_to_top_ = false; const bool use_code_font = code_font && mode_ != Mode::history; if (use_code_font) ImGui::PushFont(code_font, 0.0f); const float row_height = scaled(21, scale); - const float content_width = std::max(ImGui::GetContentRegionAvail().x, scaled(200.0f, scale)); - const SyntaxLanguage language = languageForPath(path_); + const ImVec2 child_minimum = ImGui::GetWindowPos(); + const ImVec2 child_size = ImGui::GetWindowSize(); + const float content_width = std::max( + ImGui::GetContentRegionAvail().x - (show_minimap ? minimap_width + minimap_gap + scrollbar_width + scaled(6.0f, scale) : 0.0f), + scaled(200.0f, scale)); + const float wrapable_text_width = std::max(scaled(32.0f, scale), content_width - scaled(12.0f, scale)); + const float code_character_width = ImGui::CalcTextSize("M").x; + const int wrap_columns = line_wrap_ && code_character_width > 0.0f + ? std::max(12, static_cast(wrapable_text_width / code_character_width)) + : 0; // Keep the first source row clear of the toolbar and aligned with a normal row inset. ImGui::Dummy({0.0f, row_height}); @@ -803,7 +1033,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac 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); + scale, background, code_gutter, content_width, 0.0f, wrap_columns); 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)); @@ -816,28 +1046,67 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac if (pending_hunk >= 0 && pending_hunk < static_cast(hunks_.size())) { const bool cached = pending_action != HunkAction::discard; const bool reverse = pending_action != HunkAction::stage; - if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice)) + if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice)) { + if (pending_action == HunkAction::stage) close(); 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 << "| "; + if (blame_lines_.empty()) { + ImGui::TextDisabled("No blame data is available for this file."); + } else { + SyntaxState syntax; + const float info_width = scaled(270.0f, scale); + const float line_number_width = scaled(48.0f, scale); + const float code_gutter = info_width + line_number_width + scaled(14.0f, scale); + for (size_t index = 0; index < blame_lines_.size(); ++index) { + ImGui::PushID(static_cast(index)); + const BlameLine& line = blame_lines_[index]; + const ImVec2 line_minimum = ImGui::GetCursorScreenPos(); + const ImU32 accent = blameColor(line.hash, line.show_attribution ? 255 : 190); + const ImU32 background = line.show_attribution ? IM_COL32(39, 42, 50, 150) : IM_COL32(31, 34, 40, 105); + const float blame_row_height = line.show_attribution ? scaled(28.0f, scale) : scaled(21.0f, scale); + drawCodeLine(line.text, language, syntax, scale, background, code_gutter, content_width, + blame_row_height, wrap_columns); + ImDrawList* draw = ImGui::GetWindowDrawList(); + const ImVec2 line_maximum = ImGui::GetItemRectMax(); + draw->AddRectFilled({line_minimum.x, line_minimum.y}, + {line_minimum.x + scaled(3.0f, scale), line_maximum.y}, accent); + drawCodeLineNumber(line.line_number, line_minimum.x + info_width + scaled(6.0f, scale), + line_minimum.y + scaled(2.0f, scale), IM_COL32(126, 132, 142, 255)); + if (line.show_attribution) { + const float avatar_size = scaled(16.0f, scale); + const ImVec2 avatar_min{line_minimum.x + scaled(8.0f, scale), line_minimum.y + scaled(2.0f, scale)}; + const unsigned int avatar_texture = avatars ? avatars->textureFor(line.email) : 0; + if (avatar_texture) { + draw->AddImageRounded(ImTextureRef(static_cast(avatar_texture)), + avatar_min, {avatar_min.x + avatar_size, avatar_min.y + avatar_size}, + {0, 0}, {1, 1}, IM_COL32_WHITE, scaled(3.0f, scale)); + } else { + draw->AddRectFilled(avatar_min, {avatar_min.x + avatar_size, avatar_min.y + avatar_size}, + accent, scaled(3.0f, scale)); + } + const float info_x = avatar_min.x + avatar_size + scaled(8.0f, scale); + const float info_right = line_minimum.x + info_width - scaled(10.0f, scale); + const float info_text_width = std::max(0.0f, info_right - info_x); + const ImVec2 info_clip_min{info_x, line_minimum.y}; + const ImVec2 info_clip_max{info_right, line_maximum.y}; + const std::string author = line.author.empty() ? "Unknown author" : line.author; + std::string summary = line.summary.empty() ? "(no summary)" : line.summary; + if (!line.date.empty()) summary += " " + line.date; + drawEllipsizedText(draw, {info_x, line_minimum.y + scaled(1.0f, scale)}, + IM_COL32(216, 220, 226, 255), author, info_text_width, info_clip_min, info_clip_max); + drawEllipsizedText(draw, {info_x, line_minimum.y + scaled(10.0f, scale)}, + IM_COL32(134, 140, 151, 255), summary, info_text_width, info_clip_min, info_clip_max); + } else { + draw->AddText({line_minimum.x + scaled(16.0f, scale), line_minimum.y + scaled(2.0f, scale)}, + IM_COL32(92, 99, 110, 255), "..."); + } + ImGui::PopID(); } - 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_ : &history_lines_; + } else if (mode_ == Mode::file) { + const std::vector* lines = &file_lines_; if (mode_ == Mode::file) { SyntaxState syntax; const float code_gutter = scaled(52.0f, scale); @@ -845,31 +1114,63 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac 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); + code_gutter, content_width, 0.0f, wrap_columns); 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."); + } else { + if (history_entries_.empty()) { + ImGui::TextDisabled("No history data is available for this file."); + } else { + for (size_t index = 0; index < history_entries_.size(); ++index) { + ImGui::PushID(static_cast(index)); + const HistoryEntry& entry = history_entries_[index]; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const float history_row_height = scaled(36.0f, scale); + ImGui::InvisibleButton("##history_row", {content_width, history_row_height}); + const ImVec2 row_max = ImGui::GetItemRectMax(); + ImDrawList* draw = ImGui::GetWindowDrawList(); + const bool hovered = ImGui::IsItemHovered(); + draw->AddRectFilled(row_min, row_max, + hovered ? IM_COL32(49, 53, 61, 180) : IM_COL32(37, 40, 47, 130), scaled(4.0f, scale)); + draw->AddRectFilled(row_min, {row_min.x + scaled(3.0f, scale), row_max.y}, + IM_COL32(23, 181, 204, 220), scaled(3.0f, scale)); + + const std::string hash_text = entry.hash.empty() ? "-" : entry.hash; + draw->AddText({row_min.x + scaled(10.0f, scale), row_min.y + scaled(4.0f, scale)}, + IM_COL32(96, 184, 235, 255), hash_text.c_str()); + + const std::string author_date = entry.author + + (entry.date.empty() ? std::string{} : std::string(" ") + entry.date); + draw->AddText({row_min.x + scaled(78.0f, scale), row_min.y + scaled(4.0f, scale)}, + IM_COL32(188, 192, 198, 255), author_date.c_str()); + + const std::string summary = entry.summary.empty() ? "(no summary)" : entry.summary; + draw->AddText({row_min.x + scaled(10.0f, scale), row_min.y + scaled(18.0f, scale)}, + IM_COL32(219, 223, 229, 255), summary.c_str()); + ImGui::PopID(); + } + } } main_scroll_y = ImGui::GetScrollY(); main_window_height = ImGui::GetWindowHeight(); rendered_content_height = ImGui::GetCursorPosY(); + const float max_scroll_y = ImGui::GetScrollMaxY(); + if (show_minimap) { + const float minimap_height = std::max(scaled(40.0f, scale), child_size.y - scaled(10.0f, scale)); + const float minimap_x = child_minimum.x + child_size.x - scrollbar_width - minimap_width - scaled(4.0f, scale); + const float minimap_y = child_minimum.y + scaled(4.0f, scale); + ImGui::SetCursorScreenPos({minimap_x, minimap_y}); + ImGui::PushClipRect(child_minimum, {child_minimum.x + child_size.x, child_minimum.y + child_size.y}, true); + drawMinimap(minimap_entries, {minimap_width, minimap_height}, scale, + rendered_content_height, main_scroll_y, main_window_height, max_scroll_y); + ImGui::PopClipRect(); + } 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 c3a2b78..16b86a9 100644 --- a/src/ui/diff_viewer.h +++ b/src/ui/diff_viewer.h @@ -44,6 +44,12 @@ private: int line_number = 0; bool show_attribution = false; }; + struct HistoryEntry { + std::string hash; + std::string date; + std::string author; + std::string summary; + }; std::string path_; std::string commit_id_; @@ -53,10 +59,13 @@ private: std::vector hunks_; std::vector file_lines_; std::vector blame_lines_; - std::vector history_lines_; + std::vector history_entries_; + bool line_wrap_ = false; + bool scroll_to_top_ = false; 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); + void parseHistory(const std::string& text); }; diff --git a/src/ui/gitree_ui.cpp b/src/ui/gitree_ui.cpp index 3ad5a43..df1c8ce 100644 --- a/src/ui/gitree_ui.cpp +++ b/src/ui/gitree_ui.cpp @@ -27,11 +27,14 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include #include @@ -45,6 +48,8 @@ AvatarCache* g_avatar_cache = nullptr; std::array g_filter{}; std::array g_branch_filter{}; std::string g_notice; +std::string g_last_footer_notice; +std::chrono::steady_clock::time_point g_last_footer_notice_change{}; bool g_init_popup = false; bool g_clone_popup = false; bool g_about_popup = false; @@ -65,10 +70,14 @@ std::array g_create_repository_branch{}; std::array g_clone_repository_parent{}; std::array g_clone_repository_url{}; std::string g_git_target; +std::string g_git_target_label; 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_merge_branch_source; +std::string g_merge_branch_target; +bool g_merge_branch_popup = false; std::string g_pending_commit_jump; RepositoryView* g_expanded_refs_repository = nullptr; int g_expanded_refs_commit = -1; @@ -115,6 +124,54 @@ float g_outline_icon_size = 15.0f; enum class ToolbarActionRequest { none, pull, push }; ToolbarActionRequest g_pending_toolbar_action = ToolbarActionRequest::none; ToolbarActionRequest g_running_toolbar_action = ToolbarActionRequest::none; +using RefreshClock = std::chrono::steady_clock; +constexpr auto active_refresh_interval = std::chrono::seconds(2); +constexpr auto background_refresh_interval = std::chrono::seconds(5); + +enum class GitAsyncOperation { reload, capture, pull, push, checkout_branch, push_branch, fetch, stash, pop_stash }; + +struct GitAsyncRequest { + GitAsyncOperation operation = GitAsyncOperation::reload; + RepositoryView* tab = nullptr; + std::string repo_path; + std::vector arguments; + std::string branch; + std::string remote; + std::string success_notice; + std::string focus_commit; + std::string preserve_selected_hash; + int pull_mode = 1; + bool surface_errors = false; +}; + +struct GitAsyncResult { + GitAsyncOperation operation = GitAsyncOperation::reload; + RepositoryView* tab = nullptr; + std::string repo_path; + bool success = false; + std::string notice; + std::string focus_commit; + std::string preserve_selected_hash; + bool surface_errors = false; + std::unique_ptr snapshot; +}; + +std::future g_git_async_future; + +enum class ToastKind { info, success, warning, error }; + +struct ToastNotification { + int id = 0; + ToastKind kind = ToastKind::info; + std::string title; + std::string message; + float remaining = 5.0f; + bool hovered = false; +}; + +std::vector g_toasts; +int g_next_toast_id = 1; +std::string g_last_toast_notice; float ui(float value) { return value * g_ui_scale; } @@ -123,6 +180,14 @@ constexpr float sidebar_child_indent = 24.0f; constexpr float sidebar_nested_indent = 18.0f; ImVec4 change_color(FileChangeKind kind); +std::optional sidebar_section_index(std::string_view id) { + if (id == ICON_TB_DESKTOP " LOCAL") return 0; + if (id == ICON_TB_CLOUD " REMOTE") return 1; + if (id == ICON_TB_TREE " WORKTREES") return 2; + if (id == ICON_TB_FOLDER_TREE " SUBMODULES") return 3; + return std::nullopt; +} + 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); @@ -174,14 +239,446 @@ bool copy_to_clipboard(std::string_view text, const char* description) { return true; } +bool contains_case_insensitive_text(std::string_view text, std::string_view needle) { + return std::search(text.begin(), text.end(), needle.begin(), needle.end(), + [](unsigned char left, unsigned char right) { + return std::tolower(left) == std::tolower(right); + }) != text.end(); +} + +ToastKind infer_toast_kind(std::string_view message) { + if (contains_case_insensitive_text(message, "forbidden") || + contains_case_insensitive_text(message, "unable") || + contains_case_insensitive_text(message, "error") || + contains_case_insensitive_text(message, "failed") || + contains_case_insensitive_text(message, "denied") || + contains_case_insensitive_text(message, "cannot") || + contains_case_insensitive_text(message, "no remote") || + contains_case_insensitive_text(message, "unsupported")) + return ToastKind::error; + if (contains_case_insensitive_text(message, "warning") || + contains_case_insensitive_text(message, "already running") || + contains_case_insensitive_text(message, "not available")) + return ToastKind::warning; + if (contains_case_insensitive_text(message, "complete") || + contains_case_insensitive_text(message, "created") || + contains_case_insensitive_text(message, "updated") || + contains_case_insensitive_text(message, "copied") || + contains_case_insensitive_text(message, "opened") || + contains_case_insensitive_text(message, "staged") || + contains_case_insensitive_text(message, "unstaged") || + contains_case_insensitive_text(message, "merged") || + contains_case_insensitive_text(message, "applied") || + contains_case_insensitive_text(message, "discarded")) + return ToastKind::success; + return ToastKind::info; +} + +std::string toast_title(ToastKind kind) { + switch (kind) { + case ToastKind::success: return "Success"; + case ToastKind::warning: return "Warning"; + case ToastKind::error: return "Error"; + default: return "Notice"; + } +} + +void push_toast(std::string message, ToastKind kind = ToastKind::info) { + if (message.empty()) return; + ToastNotification toast; + toast.id = g_next_toast_id++; + toast.kind = kind; + toast.title = toast_title(kind); + toast.message = std::move(message); + toast.remaining = kind == ToastKind::error ? 9.0f : (kind == ToastKind::warning ? 7.0f : 5.0f); + g_toasts.push_back(std::move(toast)); + if (g_toasts.size() > 6) g_toasts.erase(g_toasts.begin(), g_toasts.end() - 6); +} + +void sync_notice_toast() { + if (g_notice.empty()) { + g_last_toast_notice.clear(); + return; + } + if (g_notice == g_last_toast_notice) return; + g_last_toast_notice = g_notice; + if (g_notice.ends_with("...")) return; + push_toast(g_notice, infer_toast_kind(g_notice)); +} + +float footer_notice_alpha() { + if (g_notice.empty()) { + g_last_footer_notice.clear(); + return 0.0f; + } + if (g_notice != g_last_footer_notice) { + g_last_footer_notice = g_notice; + g_last_footer_notice_change = RefreshClock::now(); + } + if (g_notice.ends_with("...")) return 1.0f; + + constexpr float notice_hold_seconds = 2.0f; + constexpr float notice_fade_seconds = 1.5f; + const float elapsed = std::chrono::duration( + RefreshClock::now() - g_last_footer_notice_change).count(); + if (elapsed <= notice_hold_seconds) return 1.0f; + if (elapsed >= notice_hold_seconds + notice_fade_seconds) return 0.0f; + return 1.0f - ((elapsed - notice_hold_seconds) / notice_fade_seconds); +} + +void transfer_repository_state(RepositoryView& source, RepositoryView& target) { + if (&source == &target) return; + target.close(); + target.repo = source.repo; + source.repo = nullptr; + target.commit_walk = source.commit_walk; + source.commit_walk = nullptr; + target.history_exhausted = source.history_exhausted; + target.credentials_checked = source.credentials_checked; + target.path = std::move(source.path); + target.name = std::move(source.name); + target.branch = std::move(source.branch); + target.local_branches = std::move(source.local_branches); + target.remote_branches = std::move(source.remote_branches); + target.local_branch_divergence = std::move(source.local_branch_divergence); + target.remote_branch_divergence = std::move(source.remote_branch_divergence); + target.remotes = std::move(source.remotes); + target.worktrees = std::move(source.worktrees); + target.worktree_branches = std::move(source.worktree_branches); + target.submodules = std::move(source.submodules); + target.submodule_statuses = std::move(source.submodule_statuses); + target.commits = std::move(source.commits); + target.working_files = std::move(source.working_files); + target.selected_commit = source.selected_commit; + target.scroll_to_commit = source.scroll_to_commit; + target.last_background_refresh = source.last_background_refresh; + target.pending_background_refresh = source.pending_background_refresh; + target.undo_action = std::move(source.undo_action); + target.redo_action = std::move(source.redo_action); +} + +std::string selected_commit_hash(const RepositoryView& repository) { + if (repository.selected_commit < 0 || repository.selected_commit >= static_cast(repository.commits.size())) + return {}; + return oid_string(repository.commits[static_cast(repository.selected_commit)].oid); +} + +void restore_selected_commit(RepositoryView& repository, const std::string& hash) { + if (hash.empty()) return; + git_oid target{}; + if (git_oid_fromstr(&target, hash.c_str()) != 0) return; + for (size_t index = 0; index < repository.commits.size(); ++index) { + if (git_oid_equal(&repository.commits[index].oid, &target) != 0) { + repository.selected_commit = static_cast(index); + return; + } + } +} + +RepositoryView* find_repository_tab(RepositoryView* tab) { + const auto iterator = std::find_if(g_tabs.begin(), g_tabs.end(), [tab](const auto& item) { + return item.get() == tab; + }); + return iterator == g_tabs.end() ? nullptr : iterator->get(); +} + +void mark_repository_refreshed(RepositoryView& repository) { + repository.last_background_refresh = RefreshClock::now(); + repository.pending_background_refresh = false; +} + +GitAsyncResult execute_git_async_request(const GitAsyncRequest& request) { + GitAsyncResult result; + result.operation = request.operation; + result.tab = request.tab; + result.repo_path = request.repo_path; + result.focus_commit = request.focus_commit; + result.preserve_selected_hash = request.preserve_selected_hash; + result.surface_errors = request.surface_errors; + + GitManager manager; + RepositoryView repository; + if (!manager.openRepositoryHandle(repository, request.repo_path, result.notice)) return result; + + bool action_ok = true; + switch (request.operation) { + case GitAsyncOperation::reload: + action_ok = manager.reload(repository, result.notice); + break; + case GitAsyncOperation::capture: { + std::string output; + action_ok = manager.captureGit(repository, request.arguments, output, result.notice) && + manager.reload(repository, result.notice); + if (action_ok) result.notice = request.success_notice; + break; + } + case GitAsyncOperation::pull: + action_ok = manager.pull(repository, request.pull_mode, result.notice); + break; + case GitAsyncOperation::push: + action_ok = manager.push(repository, result.notice); + break; + case GitAsyncOperation::checkout_branch: + action_ok = manager.checkoutBranch(repository, request.branch, result.notice); + break; + case GitAsyncOperation::push_branch: + action_ok = manager.pushBranch(repository, request.branch, result.notice); + break; + case GitAsyncOperation::fetch: + action_ok = manager.fetch(repository, request.remote, result.notice); + break; + case GitAsyncOperation::stash: + action_ok = manager.stash(repository, result.notice); + break; + case GitAsyncOperation::pop_stash: + action_ok = manager.popStash(repository, result.notice); + break; + } + if (!action_ok) return result; + + auto snapshot = std::make_unique(); + transfer_repository_state(repository, *snapshot); + result.snapshot = std::move(snapshot); + result.success = true; + return result; +} + +void apply_git_async_result(GitAsyncResult result) { + if (result.operation == GitAsyncOperation::pull || result.operation == GitAsyncOperation::push) + g_running_toolbar_action = ToolbarActionRequest::none; + if (result.operation == GitAsyncOperation::checkout_branch) + g_running_branch_checkout.clear(); + RepositoryView* target = find_repository_tab(result.tab); + if (!target || target->path != result.repo_path) { + if ((result.success || result.surface_errors) && !result.notice.empty()) g_notice = result.notice; + return; + } + if (result.success && result.snapshot) { + transfer_repository_state(*result.snapshot, *target); + if (!result.focus_commit.empty()) { + if (target == &repo()) g_pending_commit_jump = result.focus_commit; + else restore_selected_commit(*target, result.focus_commit); + } else { + restore_selected_commit(*target, result.preserve_selected_hash); + } + mark_repository_refreshed(*target); + } + if ((result.success || result.surface_errors) && !result.notice.empty()) g_notice = result.notice; +} + +bool git_async_busy() { + return g_git_async_future.valid() && + g_git_async_future.wait_for(std::chrono::seconds(0)) != std::future_status::ready; +} + +void poll_git_async_result() { + if (!g_git_async_future.valid()) return; + if (g_git_async_future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; + apply_git_async_result(g_git_async_future.get()); +} + +bool start_git_async_request(GitAsyncRequest request, const char* busy_notice = "Git operation already running") { + poll_git_async_result(); + if (git_async_busy()) { + if (busy_notice && *busy_notice) g_notice = busy_notice; + return false; + } + if (!request.tab || request.repo_path.empty()) return false; + g_git_async_future = std::async(std::launch::async, [request = std::move(request)]() { + return execute_git_async_request(request); + }); + return true; +} + +bool reload_repository_background(RepositoryView& repository, bool surface_errors = false) { + if (!g_git_manager || !repository.repo || repository.path.empty()) return false; + GitAsyncRequest request; + request.operation = GitAsyncOperation::reload; + request.tab = &repository; + request.repo_path = repository.path; + request.surface_errors = surface_errors; + request.preserve_selected_hash = selected_commit_hash(repository); + const bool started = start_git_async_request(std::move(request), surface_errors + ? "A Git operation is already running" + : ""); + if (started) { + repository.pending_background_refresh = false; + repository.last_background_refresh = RefreshClock::now(); + } + return started; +} + +void process_repository_background_refreshes() { + poll_git_async_result(); + if (!g_git_manager || g_tabs.empty()) return; + if (git_async_busy()) return; + if (g_running_toolbar_action != ToolbarActionRequest::none) return; + if (!g_running_branch_checkout.empty() || !g_requested_branch_checkout.empty()) return; + const RefreshClock::time_point now = RefreshClock::now(); + for (size_t index = 0; index < g_tabs.size(); ++index) { + RepositoryView& repository = *g_tabs[index]; + if (!repository.repo || repository.path.empty()) continue; + const auto interval = index == g_active_tab ? active_refresh_interval : background_refresh_interval; + const bool due = repository.pending_background_refresh || + repository.last_background_refresh.time_since_epoch().count() == 0 || + now - repository.last_background_refresh >= interval; + if (due) { + reload_repository_background(repository, index == g_active_tab); + break; + } + } +} + 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; + GitAsyncRequest request; + request.operation = GitAsyncOperation::capture; + request.tab = &repo(); + request.repo_path = repo().path; + request.arguments = arguments; + request.success_notice = success_notice; + request.focus_commit = focus_commit; + request.preserve_selected_hash = selected_commit_hash(repo()); + return start_git_async_request(std::move(request)); +} + +void mark_action_refresh(RepositoryView& repository) { + mark_repository_refreshed(repository); + repository.pending_background_refresh = true; +} + +void draw_toasts() { + sync_notice_toast(); + if (g_toasts.empty()) return; + + const float margin = ui(14.0f); + const float width = ui(340.0f); + float y = ImGui::GetMainViewport()->WorkPos.y + margin; + const float right = ImGui::GetMainViewport()->WorkPos.x + ImGui::GetMainViewport()->WorkSize.x - margin; + const float delta = ImGui::GetIO().DeltaTime; + + for (size_t index = 0; index < g_toasts.size();) { + ToastNotification& toast = g_toasts[index]; + toast.hovered = false; + + ImGui::SetNextWindowPos({right - width, y}, ImGuiCond_Always); + ImGui::SetNextWindowSize({width, 0.0f}, ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, ui(7.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {ui(10.0f), ui(9.0f)}); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {ui(8.0f), ui(6.0f)}); + const ImU32 accent_u32 = toast.kind == ToastKind::success ? IM_COL32(74, 201, 126, 255) : + toast.kind == ToastKind::warning ? IM_COL32(234, 179, 8, 255) : + toast.kind == ToastKind::error ? IM_COL32(239, 68, 68, 255) : + IM_COL32(82, 151, 245, 255); + const ImVec4 accent = ImGui::ColorConvertU32ToFloat4(accent_u32); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.11f, 0.12f, 0.15f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, accent); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(accent.x, accent.y, accent.z, 0.12f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(accent.x, accent.y, accent.z, 0.24f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(accent.x, accent.y, accent.z, 0.30f)); + + const std::string window_name = "##toast_" + std::to_string(toast.id); + ImGui::Begin(window_name.c_str(), nullptr, + ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing); + + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max = {min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()}; + ImGui::GetWindowDrawList()->AddRectFilled(min, {min.x + ui(4.0f), max.y}, accent_u32, ui(7.0f), + ImDrawFlags_RoundCornersLeft); + + ImGui::PushFont(g_bold_font, 0.0f); + ImGui::TextColored(accent, "%s", toast.title.c_str()); + ImGui::PopFont(); + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetWindowWidth() - ui(70.0f)); + if (ImGui::SmallButton((ICON_TB_COPY "##toast_copy_" + std::to_string(toast.id)).c_str())) + copy_to_clipboard(toast.message, "notification"); + ImGui::SameLine(); + bool dismiss = ImGui::SmallButton((ICON_TB_XMARK "##toast_close_" + std::to_string(toast.id)).c_str()); + + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + width - ui(26.0f)); + ImGui::TextUnformatted(toast.message.c_str()); + ImGui::PopTextWrapPos(); + + const float progress = std::clamp(toast.remaining / + (toast.kind == ToastKind::error ? 9.0f : (toast.kind == ToastKind::warning ? 7.0f : 5.0f)), 0.0f, 1.0f); + const ImVec2 progress_min = {min.x + ui(10.0f), max.y - ui(5.0f)}; + const ImVec2 progress_max = {max.x - ui(10.0f), max.y - ui(3.0f)}; + ImGui::GetWindowDrawList()->AddRectFilled(progress_min, progress_max, IM_COL32(255, 255, 255, 18), ui(2.0f)); + ImGui::GetWindowDrawList()->AddRectFilled(progress_min, + {progress_min.x + (progress_max.x - progress_min.x) * progress, progress_max.y}, accent_u32, ui(2.0f)); + + toast.hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + if (!toast.hovered) toast.remaining -= delta; + y += ImGui::GetWindowHeight() + ui(8.0f); + + ImGui::End(); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(3); + + if (dismiss || toast.remaining <= 0.0f) { + g_toasts.erase(g_toasts.begin() + static_cast(index)); + continue; + } + ++index; + } +} + +bool start_branch_checkout_async(RepositoryView& repository, const std::string& branch) { + GitAsyncRequest request; + request.operation = GitAsyncOperation::checkout_branch; + request.tab = &repository; + request.repo_path = repository.path; + request.branch = branch; + return start_git_async_request(std::move(request)); +} + +bool start_push_branch_async(RepositoryView& repository, const std::string& branch) { + GitAsyncRequest request; + request.operation = GitAsyncOperation::push_branch; + request.tab = &repository; + request.repo_path = repository.path; + request.branch = branch; + request.preserve_selected_hash = selected_commit_hash(repository); + return start_git_async_request(std::move(request)); +} + +bool start_fetch_async(RepositoryView& repository, const std::string& remote, bool surface_errors = true) { + GitAsyncRequest request; + request.operation = GitAsyncOperation::fetch; + request.tab = &repository; + request.repo_path = repository.path; + request.remote = remote; + request.surface_errors = surface_errors; + request.preserve_selected_hash = selected_commit_hash(repository); + return start_git_async_request(std::move(request), "A Git operation is already running"); +} + +bool start_toolbar_action_async(RepositoryView& repository, ToolbarActionRequest action) { + GitAsyncRequest request; + request.tab = &repository; + request.repo_path = repository.path; + request.preserve_selected_hash = selected_commit_hash(repository); + if (action == ToolbarActionRequest::pull) { + request.operation = GitAsyncOperation::pull; + request.pull_mode = g_user_data->pullMode(); + } else if (action == ToolbarActionRequest::push) { + request.operation = GitAsyncOperation::push; + } else { + return false; + } + return start_git_async_request(std::move(request)); +} + +bool start_stash_async(RepositoryView& repository, bool pop) { + GitAsyncRequest request; + request.operation = pop ? GitAsyncOperation::pop_stash : GitAsyncOperation::stash; + request.tab = &repository; + request.repo_path = repository.path; + request.preserve_selected_hash = selected_commit_hash(repository); + return start_git_async_request(std::move(request)); } void draw_dotted_bezier(ImDrawList* draw, const ImVec2& start, const ImVec2& control_start, @@ -236,6 +733,12 @@ void cancel_inline_branch() { g_focus_inline_branch = false; } +void cancel_merge_branch() { + g_merge_branch_source.clear(); + g_merge_branch_target.clear(); + g_merge_branch_popup = false; +} + void begin_inline_branch(int commit_index) { if (commit_index < 0 || commit_index >= static_cast(repo().commits.size())) { g_notice = "Create an initial commit before creating a branch"; @@ -262,6 +765,13 @@ void clear_sidebar_filter() { g_filter.fill('\0'); } +void request_branch_merge(const std::string& source_branch, const std::string& target_branch) { + if (source_branch.empty() || target_branch.empty() || source_branch == target_branch) return; + g_merge_branch_source = source_branch; + g_merge_branch_target = target_branch; + g_merge_branch_popup = true; +} + void request_branch_checkout(const std::string& branch) { g_requested_branch_checkout = branch; } @@ -284,6 +794,7 @@ void reset_repository_view() { g_expanded_refs_repository = nullptr; g_expanded_refs_commit = -1; cancel_inline_branch(); + cancel_merge_branch(); g_requested_branch_checkout.clear(); g_running_branch_checkout.clear(); g_reset_repository_view = true; @@ -294,6 +805,7 @@ void activate_repository_tab(size_t index) { g_active_tab = index; g_tab_to_select = g_tabs[index].get(); reset_repository_view(); + g_tabs[index]->pending_background_refresh = true; persist_repository_session(); } @@ -309,6 +821,7 @@ 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(); + cancel_merge_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(); @@ -321,6 +834,7 @@ void close_tab(size_t index) { bool open_repository(const char* path) { const bool opened = g_git_manager->openRepository(repo(), path, g_notice); if (opened) { + mark_repository_refreshed(repo()); if (g_user_data && path && *path) g_user_data->addRecentRepository(path); reset_repository_view(); persist_repository_session(); @@ -517,6 +1031,8 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default ImGui::PushID(id); const ImGuiID state_id = ImGui::GetID("collapse_state"); ImGuiStorage* storage = ImGui::GetStateStorage(); + if (const auto section = sidebar_section_index(id); section && g_user_data) + default_open = g_user_data->sidebarSectionOpen(*section); bool open = storage->GetBool(state_id, default_open); const ImVec2 size{ std::max(ui(24.0f), ImGui::GetContentRegionAvail().x - reserved_width), @@ -524,10 +1040,8 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default }; const bool clicked = ImGui::InvisibleButton("##collapse", size); const ImVec2 minimum = ImGui::GetItemRectMin(); - const ImVec2 maximum = ImGui::GetItemRectMax(); ImDrawList* draw = ImGui::GetWindowDrawList(); 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); @@ -558,6 +1072,8 @@ bool sidebar_collapse_row(const char* id, const std::string& label, bool default if (clicked) { open = !open; storage->SetBool(state_id, open); + if (const auto section = sidebar_section_index(id); section && g_user_data) + g_user_data->setSidebarSectionOpen(*section, open); } ImGui::PopID(); return open; @@ -607,7 +1123,10 @@ bool sidebar_item_row(const char* icon, const std::string& text, const std::stri bool sidebar_section_is_open(const char* id) { ImGui::PushID(id); - const bool open = ImGui::GetStateStorage()->GetBool(ImGui::GetID("collapse_state"), true); + bool default_open = true; + if (const auto section = sidebar_section_index(id); section && g_user_data) + default_open = g_user_data->sidebarSectionOpen(*section); + const bool open = ImGui::GetStateStorage()->GetBool(ImGui::GetID("collapse_state"), default_open); ImGui::PopID(); return open; } @@ -650,8 +1169,8 @@ bool sidebar_section_header(const char* label, int count, const char* add_toolti if (section.find("LOCAL") != std::string::npos) { 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_git_target = repo().branch; + g_git_target_label = repo().branch.empty() ? "HEAD" : repo().branch; g_branch_create_popup = true; } } @@ -667,17 +1186,19 @@ bool sidebar_section_header(const char* label, int count, const char* add_toolti return open; } -void sidebar_section_splitter(const char* id, size_t section_index, float maximum_height) { +void sidebar_section_splitter(const char* id, size_t section_index, float current_height, + float next_height, float minimum_height, size_t next_section_index, bool persist_next_height) { ImGui::PushID(id); ImGui::InvisibleButton("##vertical_resize", {-1, ui(5.0f)}); const bool active = ImGui::IsItemActive(); const bool hovered = ImGui::IsItemHovered(); if (active || hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); if (active) { - const float minimum_height = std::min(42.0f, maximum_height); - const float height = std::clamp(g_user_data->sidebarSectionHeight(section_index) + - ImGui::GetIO().MouseDelta.y / g_ui_scale, minimum_height, maximum_height); + const float total_height = current_height + next_height; + const float height = std::clamp(current_height + ImGui::GetIO().MouseDelta.y / g_ui_scale, + minimum_height, std::max(minimum_height, total_height - minimum_height)); g_user_data->setSidebarSectionHeight(section_index, height); + if (persist_next_height) g_user_data->setSidebarSectionHeight(next_section_index, total_height - height); } if (ImGui::IsItemDeactivated()) g_user_data->save(); const ImVec2 minimum = ImGui::GetItemRectMin(); @@ -709,7 +1230,8 @@ void sidebar_item_context(const std::string& item, SidebarItemKind kind) { void section(const char* label, const std::vector& items, const char* item_icon, const char* add_tooltip, const char* add_notice, SidebarItemKind kind, size_t section_index, - float body_height, float maximum_height, bool resizable) { + float body_height, float next_height, float minimum_height, size_t next_section_index, + bool persist_next_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}); @@ -747,7 +1269,9 @@ void section(const char* label, const std::vector& items, const cha } ImGui::Unindent(ui(sidebar_child_indent)); ImGui::EndChild(); - if (resizable) sidebar_section_splitter(label, section_index, maximum_height); + if (resizable) + sidebar_section_splitter(label, section_index, body_height / g_ui_scale, next_height, minimum_height, + next_section_index, persist_next_height); } else { ImGui::Separator(); } @@ -779,19 +1303,45 @@ void branch_context(const std::string& branch, bool remote) { request_branch_checkout(branch); clear_sidebar_filter(); } + if (!remote && ImGui::MenuItem(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge into current branch")) { + request_branch_merge(branch, repo().branch); + clear_sidebar_filter(); + } if (!remote && ImGui::MenuItem(ICON_TB_UPLOAD " Push branch")) { - g_git_manager->pushBranch(repo(), branch, g_notice); + start_push_branch_async(repo(), branch); 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); + start_fetch_async(repo(), slash == std::string::npos ? branch : branch.substr(0, slash)); } ImGui::Separator(); if (ImGui::MenuItem(ICON_TB_COPY " Copy branch name")) copy_to_clipboard(branch, "branch name"); ImGui::EndPopup(); } +void branch_drag_source(const std::string& branch, bool remote) { + if (!ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) return; + ImGui::SetDragDropPayload("GITREE_BRANCH", branch.c_str(), branch.size() + 1); + ImGui::TextUnformatted(remote ? ICON_TB_CLOUD " Remote branch" : ICON_TB_CODE_BRANCH " Local branch"); + ImGui::TextDisabled("%s", branch.c_str()); + ImGui::EndDragDropSource(); +} + +void branch_drag_target(const std::string& branch) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::GetDragDropPayload() != nullptr) { + ImGui::SetTooltip("%s Drop to merge into %s", ICON_TB_ARROW_RIGHT_ARROW_LEFT, branch.c_str()); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + if (!ImGui::BeginDragDropTarget()) return; + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("GITREE_BRANCH")) { + const char* source = static_cast(payload->Data); + if (source && source[0] != '\0') request_branch_merge(source, branch); + } + ImGui::EndDragDropTarget(); +} + std::string branch_divergence_text(const std::string& branch, bool remote) { const auto& divergences = remote ? repo().remote_branch_divergence @@ -827,6 +1377,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const sidebar_item_row(branch_icon, name, "branch" + id, nullptr, 0, branch_icon_color, divergence, !remote && node.full_name == repo().branch); + branch_drag_source(node.full_name, remote); + if (!remote) branch_drag_target(node.full_name); branch_context(node.full_name, remote); } draw_branch_nodes(node, branch_icon, root_group_icon, remote, id, depth + 1); @@ -836,6 +1388,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const 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_drag_source(node.full_name, remote); + if (!remote) branch_drag_target(node.full_name); branch_context(node.full_name, remote); } } @@ -843,7 +1397,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const void branch_section(const char* label, const std::vector& branches, const char* branch_icon, const char* group_icon, const char* add_tooltip, const char* add_notice, bool remote, - size_t section_index, float body_height, float maximum_height, bool resizable) { + size_t section_index, float body_height, float next_height, float minimum_height, size_t next_section_index, + bool persist_next_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}); @@ -857,7 +1412,9 @@ void branch_section(const char* label, const std::vector& branches, draw_branch_nodes(root, branch_icon, group_icon, remote, label); ImGui::Unindent(ui(sidebar_child_indent)); ImGui::EndChild(); - if (resizable) sidebar_section_splitter(label, section_index, maximum_height); + if (resizable) + sidebar_section_splitter(label, section_index, body_height / g_ui_scale, next_height, minimum_height, + next_section_index, persist_next_height); } else { ImGui::Separator(); } @@ -887,8 +1444,10 @@ void draw_sidebar(float width) { }; if (g_sidebar_collapsed) { ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ui(22.0f)) * 0.5f); - if (collapse_button(ICON_TB_CIRCLE_CHEVRON_RIGHT, "Expand sidebar")) + if (collapse_button(ICON_TB_CIRCLE_CHEVRON_RIGHT, "Expand sidebar")) { g_sidebar_collapsed = false; + if (g_user_data) g_user_data->setSidebarCollapsed(false); + } ImGui::Dummy({0.0f, ui(7.0f)}); struct CompactSection { const char* icon; @@ -899,7 +1458,7 @@ void draw_sidebar(float width) { {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()}, + {ICON_TB_FOLDER_TREE, "Submodules", repo().submodules.size()}, }; for (size_t index = 0; index < std::size(sections); ++index) { ImGui::PushID(static_cast(index)); @@ -922,7 +1481,10 @@ void draw_sidebar(float width) { 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; + if (ImGui::IsItemClicked()) { + g_sidebar_collapsed = false; + if (g_user_data) g_user_data->setSidebarCollapsed(false); + } ImGui::PopID(); } ImGui::EndChild(); @@ -931,8 +1493,10 @@ void draw_sidebar(float width) { } const int viewing_count = static_cast(repo().local_branches.size() + repo().remote_branches.size() + repo().worktrees.size() + repo().submodules.size()); - if (collapse_button(ICON_TB_CIRCLE_CHEVRON_LEFT, "Collapse sidebar")) + if (collapse_button(ICON_TB_CIRCLE_CHEVRON_LEFT, "Collapse sidebar")) { g_sidebar_collapsed = true; + if (g_user_data) g_user_data->setSidebarCollapsed(true); + } ImGui::SameLine(0, ui(5.0f)); ImGui::TextDisabled(ICON_TB_EYE " VIEWING %d", viewing_count); ImGui::Dummy({0.0f, ui(3.0f)}); @@ -943,7 +1507,7 @@ void draw_sidebar(float width) { 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)", + ImGui::InputTextWithHint("##filter", "Filter", g_filter.data(), g_filter.size()); const ImVec2 filter_min = ImGui::GetItemRectMin(); const ImVec2 filter_max = ImGui::GetItemRectMax(); @@ -967,7 +1531,7 @@ void draw_sidebar(float width) { ICON_TB_DESKTOP " LOCAL", ICON_TB_CLOUD " REMOTE", ICON_TB_TREE " WORKTREES", - ICON_TB_LAYERS_LINKED " SUBMODULES", + ICON_TB_FOLDER_TREE " SUBMODULES", }; std::array section_open{}; std::vector open_indices; @@ -982,9 +1546,12 @@ void draw_sidebar(float width) { : 0.0f; const float body_space = std::max(0.0f, ImGui::GetContentRegionAvail().y - header_space - splitter_space); std::array section_heights{}; - std::array maximum_heights{}; + std::array next_open_indices{}; + next_open_indices.fill(section_open.size()); + float minimum_height = 0.0f; if (!open_indices.empty()) { const float minimum_body = std::min(ui(42.0f), body_space / static_cast(open_indices.size())); + minimum_height = minimum_body / g_ui_scale; float remaining = body_space; for (size_t position = 0; position + 1 < open_indices.size(); ++position) { const size_t index = open_indices[position]; @@ -994,30 +1561,27 @@ void draw_sidebar(float width) { section_heights[index] = std::clamp( ui(g_user_data->sidebarSectionHeight(index)), minimum_body, maximum); remaining -= section_heights[index]; + next_open_indices[index] = open_indices[position + 1]; } section_heights[last_open] = std::max(0.0f, remaining); - for (size_t position = 0; position + 1 < open_indices.size(); ++position) { - const size_t index = open_indices[position]; - float other_heights = 0.0f; - for (size_t other_position = 0; other_position + 1 < open_indices.size(); ++other_position) - if (other_position != position) other_heights += section_heights[open_indices[other_position]]; - maximum_heights[index] = std::max(minimum_body, - body_space - other_heights - minimum_body) / g_ui_scale; - } } 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); + "Create local branch", "Create local branch", false, 0, section_heights[0], + next_open_indices[0] < section_open.size() ? section_heights[next_open_indices[0]] / g_ui_scale : 0.0f, + minimum_height, next_open_indices[0], next_open_indices[0] != last_open, section_open[0] && last_open != 0); 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); + "Add remote", "Add remote", true, 1, section_heights[1], + next_open_indices[1] < section_open.size() ? section_heights[next_open_indices[1]] / g_ui_scale : 0.0f, + minimum_height, next_open_indices[1], next_open_indices[1] != last_open, section_open[1] && last_open != 1); 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_TB_LAYERS_LINKED " SUBMODULES", repo().submodules, ICON_TB_LAYERS_LINKED, + section_heights[2], + next_open_indices[2] < section_open.size() ? section_heights[next_open_indices[2]] / g_ui_scale : 0.0f, + minimum_height, next_open_indices[2], next_open_indices[2] != last_open, section_open[2] && last_open != 2); + section(ICON_TB_FOLDER_TREE " SUBMODULES", repo().submodules, ICON_TB_FOLDER_TREE, "Add submodule", "Add submodule", SidebarItemKind::submodule, 3, - section_heights[3], maximum_heights[3], false); + section_heights[3], 0.0f, minimum_height, section_open.size(), false, false); ImGui::PopStyleVar(); ImGui::EndChild(); ImGui::EndChild(); @@ -1302,13 +1866,34 @@ void draw_commit_table() { widest_reference_row = std::max(widest_reference_row, width); } 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( + const float default_date_width = std::clamp(available_table_width * 0.20f, ui(130.0f), ui(175.0f)); + const float default_reference_width = std::clamp( 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 default_graph_width = std::min(desired_graph_width, + std::max(ui(90.0f), available_table_width - default_reference_width - default_date_width - ui(170.0f))); + float reference_width = default_reference_width; + float graph_width = default_graph_width; + float date_width = default_date_width; + if (g_user_data) { + reference_width = std::clamp(ui(g_user_data->commitTableColumnWidth(0)), + ui(120.0f), std::max(ui(120.0f), available_table_width * 0.45f)); + graph_width = std::clamp(ui(g_user_data->commitTableColumnWidth(1)), + ui(90.0f), std::max(ui(90.0f), available_table_width * 0.35f)); + date_width = std::clamp(ui(g_user_data->commitTableColumnWidth(3)), + ui(130.0f), std::max(ui(130.0f), available_table_width * 0.30f)); + if (g_user_data->commitTableColumnWidth(0) <= 0.0f) reference_width = default_reference_width; + if (g_user_data->commitTableColumnWidth(1) <= 0.0f) graph_width = default_graph_width; + if (g_user_data->commitTableColumnWidth(3) <= 0.0f) date_width = default_date_width; + } + if (reference_width + graph_width + date_width > available_table_width - ui(120.0f)) { + const float scale = std::max(0.5f, (available_table_width - ui(120.0f)) / + std::max(ui(1.0f), reference_width + graph_width + date_width)); + reference_width *= scale; + graph_width *= scale; + date_width *= scale; + } 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)); @@ -1356,6 +1941,11 @@ void draw_commit_table() { ImGui::PushFont(g_regular_font, ImGui::GetStyle().FontSizeBase * 0.5f); ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(134, 140, 151, 255)); ImGui::TableHeadersRow(); + if (g_user_data) { + for (int column = 0; column < 4; ++column) + g_user_data->setCommitTableColumnWidth(static_cast(column), + ImGui::GetColumnWidth(column) / g_ui_scale); + } ImGui::PopStyleColor(); ImGui::PopFont(); const float table_body_clip_top = ImGui::GetItemRectMax().y; @@ -1385,6 +1975,7 @@ void draw_commit_table() { }; if (!repo().working_files.empty()) { ImGui::TableNextRow(0, ui(24.0f)); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, IM_COL32(34, 37, 44, 42)); ImGui::TableSetColumnIndex(0); bool pending_hovered = false; if (graph_row_interaction("##working_tree", repo().selected_commit == -1, @@ -1465,6 +2056,8 @@ void draw_commit_table() { if (!commit_visible(commit)) continue; const float row_height = row_heights[static_cast(i)]; ImGui::TableNextRow(0, row_height); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, + (i & 1) == 0 ? IM_COL32(35, 38, 45, 34) : IM_COL32(32, 35, 41, 18)); ImGui::TableSetColumnIndex(0); if (repo().scroll_to_commit == i) { ImGui::SetScrollFromPosY(ImGui::GetCursorScreenPos().y, 0.5f); @@ -1505,6 +2098,7 @@ void draw_commit_table() { } if (ImGui::MenuItem(ICON_TB_CODE_BRANCH " Create branch here")) { g_git_target = commit_hash; + g_git_target_label = std::string("commit ") + commit.short_id; g_branch_create_popup = true; } ImGui::Separator(); @@ -1690,54 +2284,59 @@ ImVec4 change_color(FileChangeKind kind) { } struct FileTypeBadge { - const char* label = nullptr; - ImU32 background = IM_COL32(66, 72, 82, 255); - ImU32 text = IM_COL32(226, 229, 233, 255); + const char* icon = nullptr; + ImU32 color = IM_COL32(178, 184, 194, 255); }; FileTypeBadge file_type_badge(std::string_view path) { - const auto extension = std::filesystem::path(path).extension().string(); + const std::filesystem::path file_path(path); + const auto extension = file_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)}; + std::string filename = file_path.filename().string(); + std::transform(filename.begin(), filename.end(), filename.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + if (lowered == ".c") return {ICON_TB_FILE_CODE, IM_COL32(94, 140, 214, 255)}; + if (lowered == ".h") return {ICON_TB_FILE_CODE, IM_COL32(112, 138, 192, 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 {ICON_TB_BRAND_CPP, IM_COL32(74, 132, 225, 255)}; + if (lowered == ".cs") return {ICON_TB_BRAND_C_SHARP, IM_COL32(143, 101, 217, 255)}; + if (lowered == ".rs") return {ICON_TB_BRAND_RUST, IM_COL32(196, 122, 72, 255)}; + if (lowered == ".go") return {ICON_TB_BRAND_GOLANG, IM_COL32(71, 188, 202, 255)}; + if (lowered == ".py" || lowered == ".pyw") return {ICON_TB_BRAND_PYTHON, IM_COL32(94, 154, 222, 255)}; + if (lowered == ".jsx") return {ICON_TB_BRAND_JAVASCRIPT, IM_COL32(233, 212, 96, 255)}; + if (lowered == ".tsx") return {ICON_TB_BRAND_TYPESCRIPT, IM_COL32(80, 149, 232, 255)}; + if (lowered == ".js" || lowered == ".mjs" || lowered == ".cjs") + return {ICON_TB_BRAND_JAVASCRIPT, IM_COL32(233, 212, 96, 255)}; + if (lowered == ".ts") return {ICON_TB_BRAND_TYPESCRIPT, IM_COL32(80, 149, 232, 255)}; + if (lowered == ".lua") return {ICON_TB_FILE_CODE, IM_COL32(104, 126, 212, 255)}; + if (lowered == ".sh" || lowered == ".bash" || lowered == ".zsh") return {ICON_TB_TERMINAL_2, IM_COL32(103, 166, 117, 255)}; + if (lowered == ".bat" || lowered == ".cmd") return {ICON_TB_TERMINAL_2, IM_COL32(142, 142, 154, 255)}; + if (lowered == ".ps1") return {ICON_TB_BRAND_POWERSHELL, IM_COL32(84, 154, 220, 255)}; + if (lowered == ".json") return {ICON_TB_JSON, IM_COL32(208, 184, 95, 255)}; + if (lowered == ".xml" || lowered == ".html" || lowered == ".htm") return {ICON_TB_BRAND_HTML5, IM_COL32(221, 117, 88, 255)}; + if (lowered == ".css" || lowered == ".scss") return {ICON_TB_BRAND_CSS3, IM_COL32(99, 133, 221, 255)}; + if (lowered == ".md") return {ICON_TB_MARKDOWN, IM_COL32(134, 144, 160, 255)}; + if (lowered == ".yml" || lowered == ".yaml" || lowered == ".toml") return {ICON_TB_FILE_TEXT, IM_COL32(170, 121, 108, 255)}; + if (lowered == ".sql") return {ICON_TB_FILE_TYPE_SQL, IM_COL32(104, 178, 184, 255)}; + if (filename == "package.json" || filename == "package-lock.json" || filename == ".npmrc") + return {ICON_TB_BRAND_NODEJS, IM_COL32(101, 184, 108, 255)}; return {}; } float file_type_badge_width(const FileTypeBadge& badge) { - return badge.label ? ImGui::CalcTextSize(badge.label).x + ui(10.0f) : 0.0f; + return badge.icon ? outline_icon_width(badge.icon) + ui(6.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); + if (!badge.icon) return; + const float icon_x = minimum.x + ui(21.0f); + draw->AddText(g_outline_icon_font ? g_outline_icon_font : ImGui::GetFont(), + g_outline_icon_size, {icon_x, y}, badge.color, badge.icon); } float file_action_button_width(const char* label) { @@ -1788,14 +2387,15 @@ void draw_file_row(const std::string& path, FileChangeKind kind, int id, ImDrawList* draw = ImGui::GetWindowDrawList(); 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 bool show_file_type_icon = !working_file && !commit_id.empty() && g_file_view_mode == FileViewMode::tree; + const FileTypeBadge badge = show_file_type_icon ? file_type_badge(git_path) : FileTypeBadge{}; 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_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); + const float text_x = minimum.x + ui(20.0f) + (badge.icon ? file_type_badge_width(badge) + ui(4.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(); @@ -2272,8 +2872,8 @@ 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 float label_font_size = ImGui::GetFontSize() * 0.72f; - const float icon_font_size = ImGui::GetFontSize() * 1.18f; + const float label_font_size = ImGui::GetFontSize() * 0.62f; + const float icon_font_size = ImGui::GetFontSize() * 1.34f; const ImVec2 label_size = g_regular_font ? g_regular_font->CalcTextSizeA(label_font_size, std::numeric_limits::max(), 0.0f, label) : ImGui::CalcTextSize(label); @@ -2281,12 +2881,12 @@ bool toolbar_action(const char* id, const char* label, const char* icon, const c ? 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)}, + {minimum.x + (maximum.x - minimum.x - label_size.x) * 0.5f, minimum.y + ui(4.0f)}, text_color, label); const float icon_x = minimum.x + (maximum.x - minimum.x - icon_size.x) * 0.5f - (dropdown ? ui(4.0f) : 0.0f); - draw->AddText(g_regular_font, icon_font_size, {icon_x, minimum.y + ui(21.0f)}, text_color, icon); + draw->AddText(g_regular_font, icon_font_size, {icon_x, minimum.y + ui(19.0f)}, text_color, icon); if (dropdown) - draw->AddText({icon_x + icon_size.x + ui(7.0f), minimum.y + ui(23.0f)}, text_color, ICON_TB_CARET_DOWN); + draw->AddText({icon_x + icon_size.x + ui(7.0f), minimum.y + ui(21.0f)}, text_color, ICON_TB_CARET_DOWN); if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { ImGui::BeginTooltip(); ImGui::TextUnformatted(tooltip); @@ -2376,8 +2976,8 @@ const char* application_icon(ExternalApplicationId application) { 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; + default: return ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE; } - return ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE; } unsigned int application_icon_texture(ExternalApplicationId application) { @@ -2586,6 +3186,45 @@ void draw_licenses_popup() { void draw_git_action_popups() { static bool checkout_new_branch = true; + if (g_merge_branch_popup) { + g_merge_branch_popup = false; + ImGui::OpenPopup("Merge branches"); + } + if (ImGui::BeginPopupModal("Merge branches", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge branches"); + ImGui::Separator(); + ImGui::Spacing(); + ImGui::TextUnformatted(ICON_TB_CODE_BRANCH " Source branch"); + ImGui::TextDisabled("%s", g_merge_branch_source.c_str()); + ImGui::TextUnformatted(ICON_TB_CODE_BRANCH " Target branch"); + ImGui::TextDisabled("%s", g_merge_branch_target.c_str()); + if (g_merge_branch_target != repo().branch) + ImGui::TextWrapped("%s The target branch will be checked out first, then the merge will run on that branch.", + ICON_TB_TRIANGLE_EXCLAMATION); + else + ImGui::TextWrapped("This will merge the source branch into the currently checked out branch."); + ImGui::Spacing(); + ImGui::BeginDisabled(g_merge_branch_source.empty() || g_merge_branch_target.empty() || + g_merge_branch_source == g_merge_branch_target); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.22f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.42f, 0.27f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.20f, 0.48f, 0.30f, 1.0f)); + if (ImGui::Button(ICON_TB_ARROW_RIGHT_ARROW_LEFT " Merge branch", {ui(150.0f), 0}) && + g_git_manager->mergeBranch(repo(), g_merge_branch_source, g_merge_branch_target, g_notice)) { + mark_action_refresh(repo()); + cancel_merge_branch(); + ImGui::CloseCurrentPopup(); + } + ImGui::PopStyleColor(3); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(ICON_TB_XMARK " Cancel", {ui(90.0f), 0})) { + cancel_merge_branch(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + if (g_branch_create_popup) { g_git_name.fill('\0'); checkout_new_branch = true; @@ -2599,6 +3238,9 @@ void draw_git_action_popups() { 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("Branching off: %s", g_git_target_label.empty() + ? (repo().branch.empty() ? "HEAD" : repo().branch.c_str()) + : g_git_target_label.c_str()); 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); @@ -2606,12 +3248,14 @@ void draw_git_action_popups() { 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(); + g_git_target_label.clear(); ImGui::CloseCurrentPopup(); } ImGui::EndDisabled(); ImGui::SameLine(); if (ImGui::Button("Cancel", {ui(90.0f), 0})) { g_git_target.clear(); + g_git_target_label.clear(); ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); @@ -2826,7 +3470,7 @@ void draw_popups() { std::string output; g_git_manager->captureGit(repo(), {"lfs", "install", "--local"}, output, g_notice); } - g_git_manager->reload(repo(), g_notice); + if (g_git_manager->reload(repo(), g_notice)) mark_repository_refreshed(repo()); ImGui::CloseCurrentPopup(); } } @@ -2906,7 +3550,17 @@ void draw_popups() { void draw_footer() { ImGui::Separator(); - ImGui::TextDisabled("%s", g_notice.empty() ? "Ready" : g_notice.c_str()); + const float notice_alpha = footer_notice_alpha(); + const bool show_notice = !g_notice.empty() && notice_alpha > 0.02f; + if (show_notice) { + ImVec4 notice_color = ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled); + notice_color.w *= notice_alpha; + ImGui::PushStyleColor(ImGuiCol_TextDisabled, notice_color); + ImGui::TextDisabled("%s", g_notice.c_str()); + ImGui::PopStyleColor(); + } else { + ImGui::TextDisabled("Ready"); + } const char* version = "Gitree " GITREE_VERSION; const std::string zoom_label = std::string(ICON_TB_MAGNIFYING_GLASS " ") + std::to_string(g_zoom_percent) + "%"; @@ -3052,6 +3706,11 @@ void draw_app() { 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); + bool open_repository_requested = control_down && !ImGui::GetIO().KeyShift && + ImGui::IsKeyPressed(ImGuiKey_O, false); + bool create_repository_requested = control_down && !ImGui::GetIO().KeyShift && + ImGui::IsKeyPressed(ImGuiKey_N, false); + const bool refresh_requested = ImGui::IsKeyPressed(ImGuiKey_F5, false); if (ImGui::BeginMenuBar()) { if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("New tab", "Ctrl+T")) create_tab_requested = true; @@ -3060,15 +3719,16 @@ void draw_app() { 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; + if (ImGui::MenuItem("Open repository...", "Ctrl+O")) open_repository_requested = true; + if (ImGui::MenuItem("Create repository...", "Ctrl+N")) create_repository_requested = true; ImGui::Separator(); if (ImGui::MenuItem("Exit") && g_window_manager) g_window_manager->requestClose(); ImGui::EndMenu(); } if (ImGui::BeginMenu("Edit")) { ImGui::MenuItem("Preferences", nullptr, false, false); ImGui::EndMenu(); } if (ImGui::BeginMenu("View")) { - if (ImGui::MenuItem("Refresh", "F5")) g_git_manager->reload(repo(), g_notice); + if (ImGui::MenuItem("Refresh", "F5") || refresh_requested) + reload_repository_background(repo(), true); ImGui::EndMenu(); } if (ImGui::BeginMenu("Help")) { @@ -3091,6 +3751,8 @@ void draw_app() { } } if (create_tab_requested) create_new_tab(); + if (open_repository_requested) pick_and_open_repository(); + if (create_repository_requested) g_init_popup = true; size_t tab_to_close = g_tabs.size(); size_t tab_move_from = g_tabs.size(); @@ -3315,11 +3977,13 @@ void draw_app() { begin_inline_branch(repo().selected_commit >= 0 ? repo().selected_commit : 0); } ImGui::SameLine(0, action_spacing); - if (toolbar_action("stash", "Stash", ICON_TB_BOX_ARCHIVE, "Stash changes", true, false, 58)) - g_git_manager->stash(repo(), g_notice); + if (toolbar_action("stash", "Stash", ICON_TB_BOX_ARCHIVE, "Stash changes", true, false, 58)) { + start_stash_async(repo(), false); + } ImGui::SameLine(0, action_spacing); - if (toolbar_action("pop", "Pop", ICON_TB_BOX_OPEN, "Pop latest stash", true, false, 54)) - g_git_manager->popStash(repo(), g_notice); + if (toolbar_action("pop", "Pop", ICON_TB_BOX_OPEN, "Pop latest stash", true, false, 54)) { + start_stash_async(repo(), true); + } draw_open_in_button(); ImGui::EndChild(); ImGui::PopStyleColor(); @@ -3380,16 +4044,13 @@ void draw_app() { 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 (toolbar_action_to_execute != ToolbarActionRequest::none && + start_toolbar_action_async(repo(), toolbar_action_to_execute)) + g_running_toolbar_action = toolbar_action_to_execute; if (!branch_checkout_to_execute.empty()) { - g_git_manager->checkoutBranch(repo(), branch_checkout_to_execute, g_notice); - g_running_branch_checkout.clear(); + if (!g_running_branch_checkout.empty() && start_branch_checkout_async(repo(), branch_checkout_to_execute)) + g_running_branch_checkout = branch_checkout_to_execute; } } @@ -3405,6 +4066,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_sidebar_collapsed = user_data.sidebarCollapsed(); g_zoom_percent = user_data.zoomPercent(); if (argc > 1) { @@ -3413,7 +4075,8 @@ int runGitree(int argc, char** argv) { } else if (!user_data.openRepositories().empty()) { for (const std::string& path : user_data.openRepositories()) { g_tabs.push_back(std::make_unique()); - if (!path.empty()) git_manager.openRepository(*g_tabs.back(), path, g_notice); + if (!path.empty() && git_manager.openRepositoryHandle(*g_tabs.back(), path, g_notice)) + g_tabs.back()->pending_background_refresh = true; } g_active_tab = std::min(user_data.activeRepository(), g_tabs.size() - 1); g_tab_to_select = g_tabs[g_active_tab].get(); @@ -3439,6 +4102,7 @@ int runGitree(int argc, char** argv) { while (!window_manager.shouldClose()) { window_manager.pollEvents(); + process_repository_background_refreshes(); 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)); @@ -3449,6 +4113,7 @@ int runGitree(int argc, char** argv) { ImGui::NewFrame(); avatar_cache.update(); draw_app(); + draw_toasts(); ImGui::Render(); int width = 0, height = 0; glfwGetFramebufferSize(window_manager.nativeWindow(), &width, &height); @@ -3466,6 +4131,7 @@ int runGitree(int argc, char** argv) { g_application_manager = nullptr; ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); + user_data.setSidebarCollapsed(g_sidebar_auto_collapsed ? g_sidebar_collapsed_before_viewer : g_sidebar_collapsed); user_data.setSidebarWidth(g_sidebar_width); user_data.setDetailsWidth(g_details_width); persist_repository_session(); diff --git a/src/ui/graph_renderer.cpp b/src/ui/graph_renderer.cpp index c0c65a5..bd8b1a2 100644 --- a/src/ui/graph_renderer.cpp +++ b/src/ui/graph_renderer.cpp @@ -11,6 +11,16 @@ namespace { +struct RoutedEdge { + int child_row = -1; + int parent_row = -1; + int child_lane = 0; + int parent_lane = 0; + int route_slot = 0; + int color_lane = 0; + int detour_lane = -1; +}; + template void drawRoundedPolyline(ImDrawList* draw, const std::array& points, float radius, ImU32 color, float thickness) { @@ -164,64 +174,99 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit, (row_heights[static_cast(index)] - ImGui::GetStyle().CellPadding.y * 2.0f) * 0.5f; }; + int maximum_lane = 0; + for (const auto& item : commits) maximum_lane = std::max(maximum_lane, item.lane); + + std::vector routed_edges; + routed_edges.reserve(commits.size() * 2); + const auto route_spans_overlap = [](const RoutedEdge& left, const RoutedEdge& right) { + const int top = std::max(left.child_row, right.child_row); + const int bottom = std::min(left.parent_row, right.parent_row); + return top < bottom; + }; + const auto commit_blocks_lane = [&](int candidate_lane, int child_row, int parent_row) { + 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; + }; + const auto route_blocks_lane = [&](int candidate_lane, int child_row, int parent_row) { + const RoutedEdge probe{child_row, parent_row, 0, 0, 0, 0, candidate_lane}; + return std::any_of(routed_edges.begin(), routed_edges.end(), [&](const RoutedEdge& edge) { + return edge.detour_lane == candidate_lane && route_spans_overlap(edge, probe); + }); + }; + const auto lane_available = [&](int candidate_lane, int child_row, int parent_row) { + return !commit_blocks_lane(candidate_lane, child_row, parent_row) && + !route_blocks_lane(candidate_lane, child_row, parent_row); + }; + + for (int child_row = 0; child_row < static_cast(commits.size()); ++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) continue; + + const CommitInfo& child_commit = commits[static_cast(child_row)]; + const CommitInfo& parent_commit = commits[static_cast(parent_row)]; + const int preferred_lane = parent_index == 0 ? child_commit.lane : parent_commit.lane; + int detour_lane = -1; + + if (!lane_available(preferred_lane, child_row, parent_row)) { + for (int distance = 1; distance <= maximum_lane + 2; ++distance) { + const int left = preferred_lane - distance; + const int right = preferred_lane + distance; + if (left >= 0 && lane_available(left, child_row, parent_row)) { + detour_lane = left; + break; + } + if (lane_available(right, child_row, parent_row)) { + detour_lane = right; + maximum_lane = std::max(maximum_lane, right); + break; + } + } + if (detour_lane < 0) { + detour_lane = maximum_lane + 1; + maximum_lane = detour_lane; + } + } + + RoutedEdge route; + route.child_row = child_row; + route.parent_row = parent_row; + route.child_lane = child_commit.lane; + route.parent_lane = parent_commit.lane; + route.route_slot = static_cast(parent_index); + route.detour_lane = detour_lane; + route.color_lane = detour_lane >= 0 ? detour_lane : preferred_lane; + routed_edges.push_back(route); + } + } + // Every row redraws edges crossing its clip rectangle. This keeps long paths continuous // without allowing table row clipping to cut out intermediate segments. draw->PushClipRect( {origin.x, origin.y - row_clip_padding}, {origin.x + ImGui::GetContentRegionAvail().x, origin.y + content_height + row_clip_padding}, true); - 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)]; - 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)]; + for (const RoutedEdge& route : routed_edges) { + if (row < route.child_row || row > route.parent_row || + row_heights[static_cast(route.child_row)] <= 0.0f || + row_heights[static_cast(route.parent_row)] <= 0.0f) + continue; + + const float child_x = origin.x + px(17.0f) + lane_spacing * route.child_lane; + const float parent_x = origin.x + px(17.0f) + lane_spacing * route.parent_lane; + const float child_y = center_y(route.child_row); + const float parent_y = center_y(route.parent_row); + const float detour_x = route.detour_lane >= 0 + ? origin.x + px(17.0f) + lane_spacing * route.detour_lane + : std::numeric_limits::quiet_NaN(); - 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); - 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; - } - // 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); - } + laneColor(route.color_lane, 235), scale_, route.route_slot, detour_x); } draw->PopClipRect(); diff --git a/vendor/icons/IconsTabler.h b/vendor/icons/IconsTabler.h index 6747681..5a45a51 100644 --- a/vendor/icons/IconsTabler.h +++ b/vendor/icons/IconsTabler.h @@ -12,6 +12,17 @@ #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_BRAND_C_SHARP "\xef\x80\x83" +#define ICON_TB_BRAND_CPP "\xef\x97\xbe" +#define ICON_TB_BRAND_CSS3 "\xee\xb5\xab" +#define ICON_TB_BRAND_GOLANG "\xef\x9e\x8d" +#define ICON_TB_BRAND_HTML5 "\xee\xb5\xac" +#define ICON_TB_BRAND_JAVASCRIPT "\xee\xbc\x8c" +#define ICON_TB_BRAND_NODEJS "\xef\xab\xa0" +#define ICON_TB_BRAND_POWERSHELL "\xef\x97\xad" +#define ICON_TB_BRAND_PYTHON "\xee\xb4\x81" +#define ICON_TB_BRAND_RUST "\xef\xa9\x93" +#define ICON_TB_BRAND_TYPESCRIPT "\xef\x97\xb1" #define ICON_TB_BOX_ARCHIVE "\xee\xa8\x8b" #define ICON_TB_BOX_OPEN "\xef\x81\xba" #define ICON_TB_CARET_DOWN "\xee\xad\x9d" @@ -34,13 +45,17 @@ #define ICON_TB_DOWNLOAD "\xee\xaa\x96" #define ICON_TB_EYE "\xee\xaa\x9a" #define ICON_TB_FILE "\xee\xaa\xa4" +#define ICON_TB_FILE_CODE "\xee\xaf\x90" +#define ICON_TB_FILE_TEXT "\xee\xaa\xa2" #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_JSON "\xef\x9e\xb2" #define ICON_TB_LAYERS_LINKED "\xee\xba\xa1" #define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c" +#define ICON_TB_MARKDOWN "\xee\xb1\x81" #define ICON_TB_MINUS "\xee\xab\xb2" #define ICON_TB_PEN "\xee\xac\x84" #define ICON_TB_PLUS "\xee\xac\x8b" @@ -50,6 +65,7 @@ #define ICON_TB_SERVER "\xee\xac\x9f" #define ICON_TB_TAG "\xee\xbe\x86" #define ICON_TB_TERMINAL "\xee\xaf\xaf" +#define ICON_TB_TERMINAL_2 "\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" @@ -57,3 +73,4 @@ #define ICON_TB_USER "\xee\xad\x8d" #define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa" #define ICON_TB_XMARK "\xee\xad\x95" +#define ICON_TB_FILE_TYPE_SQL "\xef\xac\x95" diff --git a/vendor/libgit2 b/vendor/libgit2 index 44c05e5..73951e9 160000 --- a/vendor/libgit2 +++ b/vendor/libgit2 @@ -1 +1 @@ -Subproject commit 44c05e5d12f2b8b86b9730bb50f27daf74143782 +Subproject commit 73951e920ab5ce99afcde9469f84aca2c8826ce2