9 Commits

Author SHA1 Message Date
9a274c148e Merge pull request 'main' (#3) from main into prod
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 2m7s
Build Releases / Build Windows x64 (push) Successful in 7m17s
Build Releases / Create Release (push) Successful in 2m10s
Reviewed-on: #3
2026-06-19 04:50:26 +00:00
b6400880e8 New build system
Some checks failed
Build Releases / Create Release (push) Has been cancelled
Build Releases / Build Windows x64 (push) Has been cancelled
Build Releases / Build Linux x64 DEB (push) Has been cancelled
2026-06-18 23:49:54 -05:00
fb98fe6106 feat(refresh): add timed background repository reloads 2026-06-18 23:49:14 -05:00
c99f0cc34a feat(history): render structured file history entries 2026-06-18 23:47:19 -05:00
5c6cd4649f added more alications 2026-06-18 23:46:08 -05:00
0b220a382e feat(blame): restore rich code view and minimap 2026-06-18 23:45:30 -05:00
5a2cffc177 fix(commits): add subtle alternating row backgrounds 2026-06-18 23:43:30 -05:00
6214c97b28 feat(branches): show branch source in create popup 2026-06-18 23:42:41 -05:00
2c7cabb0f9 fix(sidebar): keep section resize local to neighbors 2026-06-18 23:40:50 -05:00
7 changed files with 934 additions and 133 deletions

View File

@@ -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" <<EOF
Package: ${APP_NAME}
Version: ${version}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: GigabiteStudios <ceo@gigabitestudios.xyz>
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}"
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

View File

@@ -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<std::filesystem::path> 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<std::string> 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<std::string> 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;
}
}

View File

@@ -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<LaunchTarget> targets_;
std::vector<ExternalApplication> applications_;
};
};

View File

@@ -1,6 +1,7 @@
#pragma once
#include <git2.h>
#include <chrono>
#include <map>
#include <set>
#include <string>
@@ -85,6 +86,8 @@ struct RepositoryView {
std::vector<WorkingFile> 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;

View File

@@ -442,8 +442,8 @@ void drawMinimap(const std::vector<MinimapEntry>& entries, float 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) {
const float row_height = custom_row_height > 0.0f ? custom_row_height : scaled(21.0f, scale);
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale));
ImGui::InvisibleButton("##code_line", {width, row_height});
@@ -491,7 +491,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 +583,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<std::string> arguments;
if (commit_id_.empty()) {
@@ -620,7 +644,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,17 +673,16 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
parseBlame(output);
} else if (mode == Mode::history) {
std::vector<std::string> 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);
@@ -713,7 +736,7 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
ImGui::Separator();
}
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file;
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file || mode_ == Mode::blame || mode_ == Mode::history;
const float minimap_width = scaled(56.0f, scale);
float main_scroll_y = 0.0f;
float main_window_height = 0.0f;
@@ -736,10 +759,18 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
}
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
}
} 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)});
} else if (mode_ == Mode::blame) {
minimap_entries.reserve(blame_lines_.size());
for (const BlameLine& line : blame_lines_)
minimap_entries.push_back({line.text.size(), blameColor(line.hash, line.show_attribution ? 200 : 125)});
} else {
minimap_entries.reserve(history_entries_.size());
for (const HistoryEntry& entry : history_entries_)
minimap_entries.push_back({entry.summary.size() + entry.author.size(), IM_COL32(112, 118, 128, 255)});
}
}
@@ -820,24 +851,57 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
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<int>(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);
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<ImTextureID>(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 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;
draw->AddText({info_x, line_minimum.y + scaled(1.0f, scale)},
IM_COL32(216, 220, 226, 255), author.c_str());
draw->AddText({info_x, line_minimum.y + scaled(10.0f, scale)},
IM_COL32(134, 140, 151, 255), summary.c_str());
} 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<std::string>* lines = mode_ == Mode::file ? &file_lines_ : &history_lines_;
} else if (mode_ == Mode::file) {
const std::vector<std::string>* lines = &file_lines_;
if (mode_ == Mode::file) {
SyntaxState syntax;
const float code_gutter = scaled(52.0f, scale);
@@ -850,10 +914,41 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCac
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<int>(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();

View File

@@ -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,11 @@ private:
std::vector<Hunk> hunks_;
std::vector<std::string> file_lines_;
std::vector<BlameLine> blame_lines_;
std::vector<std::string> history_lines_;
std::vector<HistoryEntry> history_entries_;
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);
};

View File

@@ -65,6 +65,7 @@ std::array<char, 128> g_create_repository_branch{};
std::array<char, 1024> g_clone_repository_parent{};
std::array<char, 1024> g_clone_repository_url{};
std::string g_git_target;
std::string g_git_target_label;
std::array<char, 256> g_inline_branch_name{};
std::string g_inline_branch_target;
std::string g_requested_branch_checkout;
@@ -115,6 +116,9 @@ 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);
float ui(float value) { return value * g_ui_scale; }
@@ -174,11 +178,45 @@ bool copy_to_clipboard(std::string_view text, const char* description) {
return true;
}
void mark_repository_refreshed(RepositoryView& repository) {
repository.last_background_refresh = RefreshClock::now();
repository.pending_background_refresh = false;
}
bool reload_repository_background(RepositoryView& repository, bool surface_errors = false) {
if (!g_git_manager || !repository.repo || repository.path.empty()) return false;
std::string error;
repository.pending_background_refresh = false;
repository.last_background_refresh = RefreshClock::now();
if (!g_git_manager->reload(repository, error)) {
if (surface_errors && !error.empty()) g_notice = error;
return false;
}
return true;
}
void process_repository_background_refreshes() {
if (!g_git_manager || g_tabs.empty()) 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);
}
}
bool run_repository_action(const std::vector<std::string>& 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;
mark_repository_refreshed(repo());
if (!focus_commit.empty()) g_pending_commit_jump = focus_commit;
g_notice = success_notice;
return true;
@@ -294,6 +332,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();
}
@@ -321,6 +360,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();
@@ -650,8 +690,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<size_t>(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 +707,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 +751,8 @@ void sidebar_item_context(const std::string& item, SidebarItemKind kind) {
void section(const char* label, const std::vector<std::string>& 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<int>(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 +790,9 @@ void section(const char* label, const std::vector<std::string>& 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();
}
@@ -843,7 +888,8 @@ void draw_branch_nodes(const BranchNode& parent, const char* branch_icon, const
void branch_section(const char* label, const std::vector<std::string>& 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<int>(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 +903,9 @@ void branch_section(const char* label, const std::vector<std::string>& 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();
}
@@ -982,9 +1030,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<float, 4> section_heights{};
std::array<float, 4> maximum_heights{};
std::array<size_t, 4> 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<float>(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 +1045,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_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_LAYERS_LINKED " SUBMODULES", repo().submodules, ICON_TB_LAYERS_LINKED,
"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();
@@ -1385,6 +1433,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 +1514,8 @@ void draw_commit_table() {
if (!commit_visible(commit)) continue;
const float row_height = row_heights[static_cast<size_t>(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 +1556,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();
@@ -2599,6 +2651,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 +2661,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 +2883,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();
}
}
@@ -3068,7 +3125,8 @@ void draw_app() {
}
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") && g_git_manager->reload(repo(), g_notice))
mark_repository_refreshed(repo());
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Help")) {
@@ -3413,7 +3471,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<RepositoryView>());
if (!path.empty()) git_manager.openRepository(*g_tabs.back(), path, g_notice);
if (!path.empty() && git_manager.openRepository(*g_tabs.back(), path, g_notice))
mark_repository_refreshed(*g_tabs.back());
}
g_active_tab = std::min(user_data.activeRepository(), g_tabs.size() - 1);
g_tab_to_select = g_tabs[g_active_tab].get();
@@ -3439,6 +3498,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));