67 Commits

Author SHA1 Message Date
c9ff53c77b docs(readme): use pre-rounded banner image
Some checks failed
Build Releases / Build Linux x64 DEB (push) Successful in 3m34s
Build Releases / Build Windows x64_86 (push) Failing after 18m14s
Build Releases / Create Release (push) Has been skipped
2026-06-19 19:11:06 -05:00
bec13359a8 docs(readme): simplify header banner
Some checks failed
Build Releases / Build Windows x64_86 (push) Has started running
Build Releases / Build Linux x64 DEB (push) Has been cancelled
Build Releases / Create Release (push) Has been cancelled
2026-06-19 19:08:56 -05:00
120965f507 docs(readme): add repository artwork
Some checks failed
Build Releases / Build Windows x64_86 (push) Has started running
Build Releases / Build Linux x64 DEB (push) Has started running
Build Releases / Create Release (push) Has been cancelled
2026-06-19 19:06:53 -05:00
3e4529204e docs(release): clarify x64_86 build labels
All checks were successful
Build Releases / Build Linux x64 DEB (push) Successful in 4m19s
Build Releases / Build Windows x64_86 (push) Successful in 7m6s
Build Releases / Create Release (push) Has been skipped
2026-06-19 18:57:20 -05:00
6c245468b9 Merge branch 'prod'
Some checks failed
Build Releases / Build Windows x64 (push) Has started running
Build Releases / Build Linux x64 DEB (push) Has started running
Build Releases / Create Release (push) Has been cancelled
2026-06-19 18:55:12 -05:00
b85c2b75b6 fix(build): resolve Linux compile errors
All checks were successful
Build Releases / Build Linux x64 DEB (push) Successful in 4m4s
Build Releases / Build Windows x64 (push) Successful in 7m4s
Build Releases / Create Release (push) Successful in 1m39s
2026-06-19 18:41:43 -05:00
f614199ce5 fix(build): disable Wayland on Linux CI
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 2m35s
Build Releases / Create Release (push) Has been cancelled
Build Releases / Build Windows x64 (push) Has been cancelled
2026-06-19 18:35:11 -05:00
9424c7b830 Merge branch 'main' into prod
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m35s
Build Releases / Build Windows x64 (push) Successful in 7m22s
Build Releases / Create Release (push) Successful in 1m32s
2026-06-19 18:22:14 -05:00
e4a8e5d8c9 build(cmake): use iKvxx settings backend
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m32s
Build Releases / Build Windows x64 (push) Successful in 7m20s
Build Releases / Create Release (push) Has been skipped
2026-06-19 18:21:35 -05:00
0cc7f7151a fix(workflow): sync nested submodules in CI
Some checks failed
Build Releases / Build Windows x64 (push) Has started running
Build Releases / Build Linux x64 DEB (push) Has started running
Build Releases / Create Release (push) Has been cancelled
2026-06-19 18:21:04 -05:00
bf3a50c0e5 graph rendererupdate
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m58s
Build Releases / Build Windows x64 (push) Failing after 2m15s
Build Releases / Create Release (push) Has been skipped
2026-06-19 17:17:53 -05:00
a087fe429b fix(diff-viewer): remove minimap double scrolling
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m34s
Build Releases / Build Windows x64 (push) Has been cancelled
Build Releases / Create Release (push) Has been cancelled
2026-06-19 17:09:30 -05:00
139e721fcc fix(ui): use submodule-specific sidebar icon 2026-06-19 17:08:07 -05:00
96b595b27c fix(diff-viewer): simplify blame gutter to avatar hover 2026-06-19 17:07:12 -05:00
ca6968ae5e fix(diff-viewer): repair minimap alignment and drag 2026-06-19 17:04:56 -05:00
d7fddcb728 chore(submodules): drop vendor iKv and update iKvxx 2026-06-19 17:02:34 -05:00
6059945771 fix(ui): enlarge top toolbar actions 2026-06-19 17:00:26 -05:00
181736c0c8 fix(user-data): write ikv file in binary format 2026-06-19 16:59:12 -05:00
0e08a8a190 Added submodule vendor/iKvxx 2026-06-19 16:58:34 -05:00
e68065a4e2 fix(ui): remove stray imgui ini on startup 2026-06-19 16:58:29 -05:00
069d4e341f fix(user-data): keep ikvxx persistence in text format 2026-06-19 16:57:18 -05:00
ce81922ebb fix(user-data): persist session, drafts, and layout state 2026-06-19 16:54:36 -05:00
ac9df86ef0 Fix file row stage button alignment to prevent text shift, set ref badges rounding to 0.0f to make them rectangular 2026-06-19 16:17:19 -05:00
1f800c3cef Fix WIP node alignment with 28px lanes and decrease background auto-refresh intervals to 500ms for instant updates 2026-06-19 16:08:17 -05:00
2ca9c6bf77 Change submodules icon to folder, expand row highlight left bound, scale ref badge text size down 2026-06-19 16:05:34 -05:00
7a39b4aa20 Optimize builds in CMakeLists.txt and run.bat 2026-06-19 15:59:15 -05:00
0fea9ab8a4 Fix selection reset on background refresh, support per-repo table column widths, fix minimap drag scrolling & colors, indent details panels 2026-06-19 15:52:20 -05:00
d338023e59 fix(ui, backend): optimize run.bat cache, fix push hanging, normalize path history, redesign New Tab
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m33s
Build Releases / Build Windows x64 (push) Successful in 6m31s
Build Releases / Create Release (push) Has been skipped
2026-06-19 15:37:56 -05:00
cb145fe722 fix(build): revert libgit2 submodule to upstream commit to resolve CI checkout error
Some checks failed
Build Releases / Build Windows x64 (push) Successful in 7m11s
Build Releases / Build Linux x64 DEB (push) Failing after 1m34s
Build Releases / Create Release (push) Has been skipped
2026-06-19 15:28:15 -05:00
6ecbd597b6 fix(build): revert libgit2 submodule to upstream commit to resolve CI checkout error
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 2m1s
Build Releases / Build Windows x64 (push) Successful in 8m28s
Build Releases / Create Release (push) Successful in 2m3s
2026-06-19 15:24:33 -05:00
2c07bb0132 Merge branch 'main' into prod
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m9s
Build Releases / Build Linux x64 DEB (push) Failing after 1m13s
Build Releases / Create Release (push) Failing after 10s
2026-06-19 15:17:17 -05:00
1114c05cb9 Not sure
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m14s
Build Releases / Build Windows x64 (push) Failing after 1m20s
Build Releases / Create Release (push) Has been skipped
2026-06-19 15:12:11 -05:00
52ed46a203 Merge remote-tracking branch 'origin/prod' into prod
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m2s
Build Releases / Build Linux x64 DEB (push) Failing after 1m10s
Build Releases / Create Release (push) Failing after 11s
2026-06-19 15:10:08 -05:00
9ebf3699ad fix(diff): make code minimap scrollable and compact 2026-06-19 00:50:55 -05:00
258973da57 feat(auth): prompt and store remote credentials 2026-06-19 00:47:37 -05:00
4b846a62cd fix(ui): animate toolbar action spinners 2026-06-19 00:38:38 -05:00
4dab8e8a9a Merge branch 'main' into prod 2026-06-19 00:37:04 -05:00
76634c3fd3 fix(ui): finish refresh and diff navigation polish 2026-06-19 00:36:53 -05:00
1c730302d5 perf(startup): defer repository loading 2026-06-19 00:36:43 -05:00
93f67534c6 fix(ui): reset diff viewer scroll on open 2026-06-19 00:35:41 -05:00
5ba7d46809 feat(files): use tabler icons for committed tree entries 2026-06-19 00:34:37 -05:00
f53efe92c4 fix(ui): resize top bar action labels 2026-06-19 00:33:26 -05:00
f7f95e28ae fix(ui): use folder tree icon for submodules
Some checks failed
Build Releases / Build Windows x64 (push) Failing after 1m13s
Build Releases / Build Linux x64 DEB (push) Failing after 1m23s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:32:21 -05:00
8d59d066a6 fix(build): silence libgit2 and app icon warnings 2026-06-19 00:29:46 -05:00
aebfe65352 fix(ui): ellipsize blame attribution text 2026-06-19 00:27:45 -05:00
b6fdec12f5 fix(graph): avoid lane collisions and dead ends 2026-06-19 00:21:49 -05:00
5ac621d7b5 feat(ui): add hover-paused toast notifications 2026-06-19 00:18:18 -05:00
6361002f53 perf(git): move refresh and actions off the UI thread 2026-06-19 00:15:50 -05:00
e74e6ec513 fix(refresh): resync after toolbar and branch actions
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m32s
Build Releases / Build Windows x64 (push) Successful in 7m2s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:09:21 -05:00
279fe6e7f9 fix(ui): return to main view after staging diff 2026-06-19 00:08:08 -05:00
79bd00d84d fix(shortcuts): wire repository menu accelerators 2026-06-19 00:07:22 -05:00
eeb134c6ab feat(layout): persist sidebar and commit table state 2026-06-19 00:04:41 -05:00
1ac26e3e36 chore(submodules): point libgit2 at mirror
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m58s
Build Releases / Build Windows x64 (push) Successful in 6m39s
Build Releases / Create Release (push) Has been skipped
2026-06-19 00:02:51 -05:00
b94ca105de feat(viewer): restyle toolbar and add line wrap toggle 2026-06-18 23:59:49 -05:00
60d1e67fbe chore(submodules): point imgui and glfw at mirrors
Some checks failed
Build Releases / Build Linux x64 DEB (push) Failing after 1m37s
Build Releases / Create Release (push) Has been cancelled
Build Releases / Build Windows x64 (push) Has been cancelled
2026-06-18 23:57:12 -05:00
5c9047b444 feat(viewer): overhaul code minimap interaction 2026-06-18 23:56:45 -05:00
45a4ceb72a fix(sidebar): remove section header hover fill 2026-06-18 23:51:10 -05:00
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
f869ecf46b fix(sidebar): simplify filter placeholder text 2026-06-18 23:49:59 -05: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
24 changed files with 3400 additions and 824 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
.gitea/images/id-engine.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

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_86
runs-on: ubuntu-latest
steps:
@@ -16,6 +20,22 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Sync submodules
run: |
git submodule sync --recursive
git submodule update --init --force --recursive
git submodule status --recursive
- 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_86-${short_sha}.exe" >> "$GITHUB_ENV"
echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64_86-${short_sha}" >> "$GITHUB_ENV"
- name: Install dependencies
run: |
sudo apt-get update
@@ -26,10 +46,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 +69,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 +102,189 @@ 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: Sync submodules
run: |
git submodule sync --recursive
git submodule update --init --force --recursive
git submodule status --recursive
- 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 \
libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev \
libgl1-mesa-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: Sync submodules
run: |
git submodule sync --recursive
git submodule update --init --force --recursive
git submodule status --recursive
- 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_86-${short_sha}.exe" >> "$GITHUB_ENV"
echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64_86-${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 +293,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 +300,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 +332,22 @@ jobs:
done
fi
echo
echo "## Builds"
echo
if [ "$WINDOWS_RESULT" = "success" ]; then
echo "- ${WINDOWS_EXE}"
else
echo "- Windows x64_86 failed"
fi
if [ "$LINUX_RESULT" = "success" ]; then
echo "- ${LINUX_DEB}"
else
echo "- Linux DEB x64 failed"
fi
echo
echo "## Authors"
echo
@@ -175,9 +383,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 +405,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

12
.gitmodules vendored
View File

@@ -1,15 +1,15 @@
[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
[submodule "vendor/iKv"]
path = vendor/iKv
url = https://dock-it.dev/Idea-Studios/iKv
[submodule "vendor/iKvxx"]
path = vendor/iKvxx
url = https://dock-it.dev/Idea-Studios/iKvxx

View File

@@ -17,6 +17,10 @@ set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_INSTALL OFF CACHE BOOL "" FORCE)
if(UNIX AND NOT APPLE)
set(GLFW_BUILD_X11 ON CACHE BOOL "" FORCE)
set(GLFW_BUILD_WAYLAND OFF CACHE BOOL "" FORCE)
endif()
add_subdirectory(vendor/glfw EXCLUDE_FROM_ALL)
# Dear ImGui does not ship a CMake target, so keep its static-library recipe here.
@@ -46,11 +50,9 @@ add_subdirectory(vendor/libgit2 EXCLUDE_FROM_ALL)
set(IZO_BUILD_EXAMPLE OFF CACHE BOOL "" FORCE)
add_subdirectory(vendor/iZo EXCLUDE_FROM_ALL)
# Persistent application settings and session data.
set(IKV_BUILD_DEMOS OFF CACHE BOOL "" FORCE)
set(IKV_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(IKV_INSTALL OFF CACHE BOOL "" FORCE)
add_subdirectory(vendor/iKv EXCLUDE_FROM_ALL)
# Persistent application settings and session data (C++17 bindings for iKv).
set(IKVXX_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(vendor/iKvxx EXCLUDE_FROM_ALL)
find_package(OpenGL REQUIRED)
@@ -77,7 +79,7 @@ add_executable(gitree WIN32
src/models/repository.h
)
target_include_directories(gitree PRIVATE src vendor/libgit2/include vendor/icons)
target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikv::ikv OpenGL::GL)
target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikvxx::ikvxx OpenGL::GL)
target_compile_definitions(gitree PRIVATE
GITREE_VERSION="${PROJECT_VERSION}"
GITREE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/vendor/fonts"
@@ -86,11 +88,11 @@ target_compile_definitions(gitree PRIVATE
)
if(WIN32)
target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt)
target_link_libraries(gitree PRIVATE dwmapi urlmon windowscodecs bcrypt crypt32)
endif()
if(MSVC)
target_compile_options(gitree PRIVATE /W4 /permissive-)
target_compile_options(gitree PRIVATE /W4 /permissive- /MP)
else()
target_compile_options(gitree PRIVATE -Wall -Wextra -Wpedantic)
endif()

View File

@@ -1,8 +1,8 @@
# Gitree
![Gitree banner](.gitea/images/gitree_banner_rounded.png)
[![Windows build](https://dock-it.dev/Idea-Studios/Gitree/actions/workflows/windows-build.yml/badge.svg?branch=prod)](https://dock-it.dev/Idea-Studios/Gitree/actions?workflow=windows-build.yml)
[![Latest release](https://img.shields.io/gitea/v/release/Idea-Studios/Gitree?gitea_url=https%3A%2F%2Fdock-it.dev&label=release)](https://dock-it.dev/Idea-Studios/Gitree/releases/latest)
[![Platform](https://img.shields.io/badge/platform-Windows_x64-0078D4?logo=windows)](https://dock-it.dev/Idea-Studios/Gitree/releases)
[![Latest release builds](https://img.shields.io/badge/latest%20release-Windows_x64__86_%2B_Linux_x64_DEB-2ea043)](https://dock-it.dev/Idea-Studios/Gitree/releases/latest)
[![C++](https://img.shields.io/badge/C%2B%2B-20-00599C?logo=cplusplus)](https://en.cppreference.com/w/cpp/20)
[![License](https://img.shields.io/badge/license-CC_BY--SA_4.0-lightgrey.svg)](https://dock-it.dev/Idea-Studios/Gitree/src/branch/prod/LICENSE)
@@ -10,7 +10,7 @@ A fast, native Git desktop client for Windows.
## Download
Download the current Windows x64 executable from the
Download the current Windows x64_86 executable or Linux x64 DEB from the
[latest release](https://dock-it.dev/Idea-Studios/Gitree/releases/latest), or browse
[all releases](https://dock-it.dev/Idea-Studios/Gitree/releases).
@@ -22,7 +22,7 @@ Open Gitree, choose **Open repository**, and select a local Git repository. You
also pass its path when launching from a terminal:
```powershell
.\Gitree-windows-x64.exe C:\path\to\repository
.\Gitree-windows-x64_86.exe C:\path\to\repository
```
Use the sidebar to switch branches and browse refs. Select a commit to inspect its
@@ -32,7 +32,7 @@ details and changes. The commit area lets you stage files and create commits.
### Requirements
- Windows 10 or later (x64)
- Windows 10 or later (x64_86)
- [Git](https://git-scm.com/download/win)
- [CMake 3.21 or later](https://cmake.org/download/)
- A C++20 compiler: Visual Studio 2022 with the **Desktop development with C++**
@@ -91,3 +91,7 @@ cmake --build build --parallel
Gitree is licensed under the [Creative Commons Attribution-ShareAlike 4.0
International license](https://dock-it.dev/Idea-Studios/Gitree/src/branch/prod/LICENSE).
## Powered by Idea Studios
![Idea Studios Engine](.gitea/images/id-engine.jpg)

View File

@@ -14,8 +14,10 @@ if %errorlevel%==0 (
set "GENERATOR="
)
cmake -S . -B build %GENERATOR% -DCMAKE_BUILD_TYPE=Release || exit /b 1
cmake --build build --config Release --parallel || exit /b 1
if not exist build\CMakeCache.txt (
cmake -S . -B build %GENERATOR% -DCMAKE_BUILD_TYPE=Release || exit /b 1
)
cmake --build build --config Release --parallel %NUMBER_OF_PROCESSORS% || exit /b 1
if exist build\bin\gitree.exe (
start "" build\bin\gitree.exe %*

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

@@ -4,10 +4,12 @@
#include <izo/Time.hpp>
#include <algorithm>
#include <array>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#ifdef _WIN32
@@ -80,6 +82,83 @@ 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);
}
}
std::vector<std::string> repositoryRemotes(git_repository *repository)
{
std::vector<std::string> remotes;
git_strarray names{};
if (git_remote_list(&names, repository) == 0)
{
for (size_t i = 0; i < names.count; ++i)
remotes.emplace_back(names.strings[i]);
git_strarray_dispose(&names);
}
return remotes;
}
std::string preferredRemoteName(git_repository *repository)
{
const std::vector<std::string> remotes = repositoryRemotes(repository);
if (remotes.empty())
return {};
const auto origin = std::find(remotes.begin(), remotes.end(), "origin");
return origin != remotes.end() ? *origin : remotes.front();
}
std::string encodeBase64(std::string_view value)
{
static constexpr std::string_view alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string encoded;
encoded.reserve(((value.size() + 2) / 3) * 4);
for (size_t index = 0; index < value.size(); index += 3)
{
const uint32_t a = static_cast<unsigned char>(value[index]);
const uint32_t b = index + 1 < value.size() ? static_cast<unsigned char>(value[index + 1]) : 0;
const uint32_t c = index + 2 < value.size() ? static_cast<unsigned char>(value[index + 2]) : 0;
const uint32_t chunk = (a << 16) | (b << 8) | c;
encoded.push_back(alphabet[(chunk >> 18) & 0x3F]);
encoded.push_back(alphabet[(chunk >> 12) & 0x3F]);
encoded.push_back(index + 1 < value.size() ? alphabet[(chunk >> 6) & 0x3F] : '=');
encoded.push_back(index + 2 < value.size() ? alphabet[chunk & 0x3F] : '=');
}
return encoded;
}
std::vector<std::string> withAuthOverrideArguments(std::vector<std::string> arguments,
const std::optional<GitAuthOverride> &auth)
{
arguments.insert(arguments.begin(), {
"-c", "credential.interactive=never",
});
if (!auth || auth->username.empty())
return arguments;
const std::string header = "http.extraHeader=Authorization: Basic " +
encodeBase64(auth->username + ":" + auth->password);
arguments.insert(arguments.begin() + 2, {
"-c", header,
});
return arguments;
}
void addBadge(RepositoryView &repository, const git_oid *oid, RefBadge badge)
{
if (!oid)
@@ -369,54 +448,65 @@ void GitManager::computeGraphLanes(RepositoryView &repository)
{
git_oid expected{};
};
const auto lane_for_commit = [](std::vector<Lane> &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<size_t>(std::distance(lanes.begin(), found));
lanes.push_back({oid});
return lanes.size() - 1;
};
const auto contains_oid = [](const std::vector<Lane> &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<Lane> 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<size_t>(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<int>(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<std::ptrdiff_t>(duplicate));
if (duplicate < active_lane)
--active_lane;
}
}
if (commit.parent_ids.empty())
{
lanes.erase(lanes.begin() + static_cast<std::ptrdiff_t>(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::ptrdiff_t>(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<std::ptrdiff_t>(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::ptrdiff_t>(
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<std::ptrdiff_t>(right));
}
}
}
}
@@ -528,26 +618,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 +751,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 +761,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);
}
@@ -726,10 +813,11 @@ bool GitManager::reload(RepositoryView &repository, std::string &error)
}
bool GitManager::runGit(RepositoryView &repository, const std::vector<std::string> &arguments,
const char *success_message, std::string &message, bool reload_repository)
const char *success_message, std::string &message, bool reload_repository,
const std::optional<GitAuthOverride> &auth)
{
std::string command_output;
if (!captureGit(repository, arguments, command_output, message))
if (!captureGit(repository, withAuthOverrideArguments(arguments, auth), command_output, message))
return false;
if (reload_repository)
{
@@ -745,7 +833,8 @@ bool GitManager::runGit(RepositoryView &repository, const std::vector<std::strin
}
bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::string> &arguments,
std::string &command_output, std::string &error)
std::string &command_output, std::string &error,
const std::string *stdin_data)
{
if (!repository.repo)
{
@@ -762,10 +851,24 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::s
return false;
}
SECURITY_ATTRIBUTES security{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE input_read = nullptr;
HANDLE input_write = nullptr;
if (stdin_data && !CreatePipe(&input_read, &input_write, &security, 0))
{
DeleteFileW(output_path);
error = "Unable to create Git command input pipe";
return false;
}
if (input_write)
SetHandleInformation(input_write, HANDLE_FLAG_INHERIT, 0);
HANDLE output = CreateFileW(output_path, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr);
if (output == INVALID_HANDLE_VALUE)
{
if (input_read)
CloseHandle(input_read);
if (input_write)
CloseHandle(input_write);
DeleteFileW(output_path);
error = "Unable to capture Git command output";
return false;
@@ -782,18 +885,34 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::s
startup.dwFlags = STARTF_USESTDHANDLES;
startup.hStdOutput = output;
startup.hStdError = output;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
startup.hStdInput = input_read ? input_read : GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION process{};
const BOOL started = CreateProcessW(nullptr, mutable_command.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &startup, &process);
DWORD exit_code = 1;
if (started)
{
if (input_read)
{
CloseHandle(input_read);
input_read = nullptr;
}
if (input_write && stdin_data)
{
DWORD bytes_written = 0;
WriteFile(input_write, stdin_data->data(), static_cast<DWORD>(stdin_data->size()), &bytes_written, nullptr);
CloseHandle(input_write);
input_write = nullptr;
}
WaitForSingleObject(process.hProcess, INFINITE);
GetExitCodeProcess(process.hProcess, &exit_code);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
if (input_read)
CloseHandle(input_read);
if (input_write)
CloseHandle(input_write);
FlushFileBuffers(output);
SetFilePointer(output, 0, nullptr, FILE_BEGIN);
command_output.clear();
@@ -812,6 +931,8 @@ bool GitManager::captureGit(RepositoryView &repository, const std::vector<std::s
}
#else
(void)arguments;
(void)command_output;
(void)stdin_data;
error = "Git commands are currently supported on Windows";
return false;
#endif
@@ -824,7 +945,7 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
return true;
bool uses_https = false;
for (const std::string &remote_name : repository.remotes)
for (const std::string &remote_name : repositoryRemotes(repository.repo))
{
git_remote *remote = nullptr;
if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) == 0)
@@ -865,9 +986,8 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
if (!gcm_available)
{
error = "This HTTPS remote needs authentication, but no credential helper is configured. "
"Install Git Credential Manager or configure credential.helper in Git.";
return false;
repository.credentials_checked = true;
return true;
}
std::string configure_output;
@@ -878,6 +998,78 @@ bool GitManager::prepareCredentials(RepositoryView &repository, std::string &err
return true;
}
std::string GitManager::remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error)
{
git_remote *remote = nullptr;
if (git_remote_lookup(&remote, repository.repo, remote_name.c_str()) != 0)
{
error = lastError("Unable to find remote");
return {};
}
const char *url = git_remote_url(remote);
const std::string remote_url = url ? url : "";
git_remote_free(remote);
if (remote_url.empty())
error = "Remote has no URL";
return remote_url;
}
bool GitManager::storeCredential(RepositoryView &repository, const std::string &remote_url,
const std::string &username, const std::string &password,
std::string &error)
{
if (username.empty())
{
error = "Username is required";
return false;
}
if (!prepareCredentials(repository, error))
return false;
std::string helper;
std::string helper_error;
if (!captureGit(repository, {"config", "--get-all", "credential.helper"}, helper, helper_error) || helper.empty())
{
error = helper_error.empty() ? "No Git credential helper is configured" : helper_error;
return false;
}
const size_t scheme = remote_url.find("://");
if (scheme == std::string::npos)
{
error = "Remote URL is invalid";
return false;
}
const size_t authority_begin = scheme + 3;
const size_t path_begin = remote_url.find('/', authority_begin);
const std::string protocol = remote_url.substr(0, scheme);
const std::string host = path_begin == std::string::npos
? remote_url.substr(authority_begin)
: remote_url.substr(authority_begin, path_begin - authority_begin);
const std::string path = path_begin == std::string::npos ? "/" : remote_url.substr(path_begin);
std::ostringstream input;
input << "protocol=" << protocol << "\n";
input << "host=" << host << "\n";
input << "path=" << path << "\n";
input << "username=" << username << "\n";
input << "password=" << password << "\n\n";
std::string output;
std::string payload = input.str();
return captureGit(repository, {"credential", "approve"}, output, error, &payload);
}
bool GitManager::isAuthenticationFailure(std::string_view message)
{
std::string lowered(message);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char value)
{ return static_cast<char>(std::tolower(value)); });
return lowered.find(" 403") != std::string::npos ||
lowered.find(" 401") != std::string::npos ||
lowered.find("forbidden") != std::string::npos ||
lowered.find("authentication failed") != std::string::npos ||
lowered.find("access denied") != std::string::npos ||
lowered.find("could not read username") != std::string::npos ||
lowered.find("terminal prompts disabled") != std::string::npos;
}
bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
bool reverse, std::string &error)
{
@@ -908,22 +1100,24 @@ bool GitManager::applyPatch(RepositoryView &repository, const std::string &patch
return applied;
}
bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error)
bool GitManager::fetch(RepositoryView &repository, const std::string &remote, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
const std::vector<std::string> args = remote.empty()
? std::vector<std::string>{"fetch", "--all", "--prune"}
: std::vector<std::string>{"fetch", remote, "--prune"};
return runGit(repository, args, "Fetch complete", error);
return runGit(repository, args, "Fetch complete", error, true, auth);
}
bool GitManager::pull(RepositoryView &repository, int mode, std::string &error)
bool GitManager::pull(RepositoryView &repository, int mode, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
if (mode == 0)
return fetch(repository, {}, error);
return fetch(repository, {}, error, auth);
std::vector<std::string> args{"pull"};
if (mode == 1)
args.push_back("--ff");
@@ -931,26 +1125,25 @@ bool GitManager::pull(RepositoryView &repository, int mode, std::string &error)
args.push_back("--ff-only");
else if (mode == 3)
args.push_back("--rebase");
return runGit(repository, args, "Pull complete", error);
return runGit(repository, args, "Pull complete", error, true, auth);
}
bool GitManager::push(RepositoryView &repository, std::string &error)
bool GitManager::push(RepositoryView &repository, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
if (runGit(repository, {"push"}, "Push complete", error))
if (runGit(repository, {"push"}, "Push complete", error, true, auth))
return true;
if (repository.remotes.empty())
const std::string remote = preferredRemoteName(repository.repo);
if (remote.empty())
return false;
const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") !=
repository.remotes.end()
? "origin"
: repository.remotes.front();
return runGit(repository, {"push", "--set-upstream", remote, repository.branch},
"Push complete; upstream configured", error);
"Push complete; upstream configured", error, true, auth);
}
bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error)
bool GitManager::pushBranch(RepositoryView &repository, const std::string &branch, std::string &error,
const std::optional<GitAuthOverride> &auth)
{
if (!prepareCredentials(repository, error))
return false;
@@ -982,20 +1175,16 @@ bool GitManager::pushBranch(RepositoryView &repository, const std::string &branc
if (!remote_name.empty() && !remote_branch_name.empty())
return runGit(repository, {"push", remote_name, branch + ":" + remote_branch_name},
"Push complete", error);
"Push complete", error, true, auth);
if (repository.remotes.empty())
const std::string remote = preferredRemoteName(repository.repo);
if (remote.empty())
{
error = "No remote is configured for this repository";
return false;
}
const std::string remote = std::find(repository.remotes.begin(), repository.remotes.end(), "origin") !=
repository.remotes.end()
? "origin"
: repository.remotes.front();
return runGit(repository, {"push", "--set-upstream", remote, branch + ":" + branch},
"Push complete; upstream configured", error);
"Push complete; upstream configured", error, true, auth);
}
bool GitManager::stash(RepositoryView &repository, std::string &error)
@@ -1250,6 +1439,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;

View File

@@ -1,69 +1,91 @@
#pragma once
#pragma once
#include "models/repository.h"
#include <optional>
#include <string>
#include <vector>
struct GitAuthOverride
{
std::string remote_url;
std::string username;
std::string password;
};
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 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 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,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool pull(RepositoryView &repository, int mode, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool push(RepositoryView &repository, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error,
const std::optional<GitAuthOverride> &auth = std::nullopt);
bool stash(RepositoryView &repository, std::string &error);
bool popStash(RepositoryView &repository, std::string &error);
bool undoCommit(RepositoryView &repository, std::string &error);
bool redoCommit(RepositoryView &repository, std::string &error);
bool stageAll(RepositoryView &repository, std::string &error);
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 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<std::string> &arguments,
std::string &output, std::string &error);
std::string &output, std::string &error,
const std::string *stdin_data = nullptr);
bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
bool reverse, std::string &error);
std::string remoteUrl(RepositoryView &repository, const std::string &remote_name, std::string &error);
bool storeCredential(RepositoryView &repository, const std::string &remote_url,
const std::string &username, const std::string &password,
std::string &error);
static bool isAuthenticationFailure(std::string_view message);
private:
static std::string lastError(const char *fallback);
static std::string formatTime(git_time_t value, int offset_minutes);
void loadToolbarHistoryActions(RepositoryView &repository);
void readBranches(RepositoryView &repository, git_branch_t type, std::vector<std::string> &output);
void loadBranchDivergence(RepositoryView &repository);
void loadRefBadges(RepositoryView &repository);
void computeGraphLanes(RepositoryView &repository);
bool loadRepositoryData(RepositoryView &repository, std::string &error);
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<std::string> &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<std::string> &arguments,
const char *success_message, std::string &message, bool reload = true);
const char *success_message, std::string &message, bool reload = true,
const std::optional<GitAuthOverride> &auth = std::nullopt);
};

View File

@@ -1,19 +1,24 @@
#include "user_data.h"
#include <izo/Paths.hpp>
extern "C"
{
#include <ikv.h>
}
#include <ikvxx/ikvxx.hpp>
#include <algorithm>
#include <fstream>
#include <iomanip>
#include <optional>
#include <sstream>
#include <utility>
#ifdef _WIN32
#include <windows.h>
#include <wincrypt.h>
#endif
namespace
{
using ArrayIndex = ikv::Value::ArrayIndex;
std::filesystem::path roaming_directory()
{
if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty())
@@ -23,12 +28,169 @@ namespace
return std::filesystem::temp_directory_path();
}
const ikv_node_t *object_value(const ikv_node_t *object, const char *key, ikv_type_t type)
std::optional<std::pair<std::string, std::string>> splitCredentialPayload(const std::string &payload)
{
const ikv_node_t *value = object ? ikv_object_get(object, key) : nullptr;
return value && ikv_node_type(value) == type ? value : nullptr;
const size_t separator = payload.find('\n');
if (separator == std::string::npos)
return std::nullopt;
return std::pair<std::string, std::string>{
payload.substr(0, separator),
payload.substr(separator + 1)};
}
}
#ifdef _WIN32
std::string hexEncode(const std::string &value)
{
static constexpr char digits[] = "0123456789abcdef";
std::string encoded;
encoded.reserve(value.size() * 2);
for (const unsigned char byte : value)
{
encoded.push_back(digits[(byte >> 4) & 0x0F]);
encoded.push_back(digits[byte & 0x0F]);
}
return encoded;
}
std::optional<std::string> hexDecode(const std::string &value)
{
if (value.size() % 2 != 0)
return std::nullopt;
auto decode_nibble = [](char character) -> int
{
if (character >= '0' && character <= '9')
return character - '0';
if (character >= 'a' && character <= 'f')
return 10 + (character - 'a');
if (character >= 'A' && character <= 'F')
return 10 + (character - 'A');
return -1;
};
std::string decoded;
decoded.reserve(value.size() / 2);
for (size_t index = 0; index < value.size(); index += 2)
{
const int high = decode_nibble(value[index]);
const int low = decode_nibble(value[index + 1]);
if (high < 0 || low < 0)
return std::nullopt;
decoded.push_back(static_cast<char>((high << 4) | low));
}
return decoded;
}
std::optional<std::string> protectCredentialString(const std::string &value)
{
DATA_BLOB input{};
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(value.data()));
input.cbData = static_cast<DWORD>(value.size());
DATA_BLOB output{};
if (!CryptProtectData(&input, L"Gitree Git Credential", nullptr, nullptr, nullptr,
CRYPTPROTECT_UI_FORBIDDEN, &output))
return std::nullopt;
std::string protected_value(reinterpret_cast<const char *>(output.pbData), output.cbData);
LocalFree(output.pbData);
return hexEncode(protected_value);
}
std::optional<std::string> unprotectCredentialString(const std::string &value)
{
const auto binary = hexDecode(value);
if (!binary)
return std::nullopt;
DATA_BLOB input{};
input.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(binary->data()));
input.cbData = static_cast<DWORD>(binary->size());
DATA_BLOB output{};
if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr,
CRYPTPROTECT_UI_FORBIDDEN, &output))
return std::nullopt;
std::string plain(reinterpret_cast<const char *>(output.pbData), output.cbData);
LocalFree(output.pbData);
return plain;
}
#endif
std::string normalizePath(const std::string &path)
{
if (path.empty())
return {};
try
{
std::filesystem::path p(path);
std::string result = p.lexically_normal().generic_string();
if (result.size() > 1 && result.back() == '/')
result.pop_back();
return result;
}
catch (...)
{
return path;
}
}
std::optional<float> readFloat(const ikv::Value &value)
{
if (value.isDouble())
return static_cast<float>(value.asDouble());
if (value.isInt())
return static_cast<float>(value.asInt64());
return std::nullopt;
}
// Load settings from an ikv::Value "settings" object into UserData fields.
// Returns true if anything useful was read.
void loadSettings(const ikv::Value &settings, float &sidebar_width, float &details_width,
bool &sidebar_collapsed, float &commit_message_height,
float &working_composer_height, int &pull_mode, int &zoom_percent,
std::array<float, 4> &sidebar_section_heights,
std::array<bool, 4> &sidebar_section_open,
std::array<float, 4> &commit_table_column_widths)
{
if (settings.isMember("sidebar_width"))
if (const auto value = readFloat(settings["sidebar_width"]); value)
sidebar_width = *value;
if (settings.isMember("details_width"))
if (const auto value = readFloat(settings["details_width"]); value)
details_width = *value;
if (settings.isMember("sidebar_collapsed") && settings["sidebar_collapsed"].isBool())
sidebar_collapsed = settings["sidebar_collapsed"].asBool();
if (settings.isMember("commit_message_height"))
if (const auto value = readFloat(settings["commit_message_height"]); value)
commit_message_height = *value;
if (settings.isMember("working_composer_height"))
if (const auto value = readFloat(settings["working_composer_height"]); value)
working_composer_height = *value;
if (settings.isMember("pull_mode") && settings["pull_mode"].isInt())
pull_mode = static_cast<int>(settings["pull_mode"].asInt64());
if (settings.isMember("zoom_percent") && settings["zoom_percent"].isInt())
zoom_percent = static_cast<int>(settings["zoom_percent"].asInt64());
if (settings.isMember("sidebar_sections") && settings["sidebar_sections"].isArray())
{
const ikv::Value &arr = settings["sidebar_sections"];
const auto count = std::min(arr.size(), sidebar_section_heights.size());
for (std::size_t i = 0; i < count; ++i)
if (const auto value = readFloat(arr[static_cast<ArrayIndex>(i)]); value)
sidebar_section_heights[i] = *value;
}
if (settings.isMember("sidebar_section_open") && settings["sidebar_section_open"].isArray())
{
const ikv::Value &arr = settings["sidebar_section_open"];
const auto count = std::min(arr.size(), sidebar_section_open.size());
for (std::size_t i = 0; i < count; ++i)
sidebar_section_open[i] = arr[static_cast<ArrayIndex>(i)].asBool();
}
if (settings.isMember("commit_table_columns") && settings["commit_table_columns"].isArray())
{
const ikv::Value &arr = settings["commit_table_columns"];
const auto count = std::min(arr.size(), commit_table_column_widths.size());
for (std::size_t i = 0; i < count; ++i)
if (const auto value = readFloat(arr[static_cast<ArrayIndex>(i)]); value)
commit_table_column_widths[i] = *value;
}
}
} // namespace
UserData::UserData()
{
@@ -42,170 +204,200 @@ UserData::~UserData()
save();
}
std::string UserData::credentialScope(const std::string &remote_url)
{
const size_t scheme = remote_url.find("://");
if (scheme == std::string::npos)
return remote_url;
const size_t authority_begin = scheme + 3;
const size_t authority_end = remote_url.find_first_of("/?#", authority_begin);
return remote_url.substr(0, authority_end == std::string::npos ? remote_url.size() : authority_end);
}
std::filesystem::path UserData::dataPath() const
{
return directory_ / "user_data.ikv2b";
return directory_ / "user_data.ikv";
}
void UserData::removeLegacyFiles() const
{
std::error_code error;
for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", "user_data.ikv"})
for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt",
"user_data.ikv2b", "user_data.ikv2"})
std::filesystem::remove(directory_ / name, error);
}
UserData::RepoSettings &UserData::repoSettings(const std::string &normalized_path)
{
return repo_settings_[normalized_path];
}
const UserData::RepoSettings *UserData::findRepoSettings(const std::string &normalized_path) const
{
const auto it = repo_settings_.find(normalized_path);
return it != repo_settings_.end() ? &it->second : nullptr;
}
void UserData::load()
{
const std::filesystem::path binary_path = dataPath();
if (ikv_node_t *root = ikvb_parse_file(binary_path.string().c_str()))
// ── Primary load: iKvxx binary (.ikv) ────────────────────────────────────
try
{
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
const ikv::Value root = [&]() -> ikv::Value
{
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
try
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
return ikv::Value::load(binary_path.string());
}
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
for (uint32_t index = 0; index < count; ++index)
catch (const ikv::Error &)
{
const char *path = ikv_as_string(ikv_array_get(history, index));
if (path && *path)
recently_closed_.emplace_back(path);
return ikv::Value::loadBinary(binary_path.string());
}
}
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
}();
if (root.isMember("settings") && root["settings"].isObject())
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(recent, index));
if (path && *path)
recent_repositories_.emplace_back(path);
}
loadSettings(root["settings"],
sidebar_width_, details_width_, sidebar_collapsed_,
commit_message_height_, working_composer_height_,
pull_mode_, zoom_percent_,
sidebar_section_heights_, sidebar_section_open_,
commit_table_column_widths_);
}
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
if (root.isMember("recently_closed") && root["recently_closed"].isArray())
{
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
const ikv::Value &arr = root["recently_closed"];
const auto count = std::min<std::size_t>(arr.size(), 100);
for (std::size_t i = 0; i < count; ++i)
{
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
const std::string path = arr[static_cast<ArrayIndex>(i)].asString();
if (!path.empty())
{
const char *path = ikv_as_string(ikv_array_get(tabs, index));
open_repositories_.emplace_back(path ? path : "");
std::string normalized = normalizePath(path);
if (std::find(recently_closed_.begin(), recently_closed_.end(), normalized) == recently_closed_.end())
recently_closed_.emplace_back(std::move(normalized));
}
}
}
ikv_free(root);
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
if (root.isMember("recent_repositories") && root["recent_repositories"].isArray())
{
const ikv::Value &arr = root["recent_repositories"];
const auto count = std::min<std::size_t>(arr.size(), 100);
for (std::size_t i = 0; i < count; ++i)
{
const std::string path = arr[static_cast<ArrayIndex>(i)].asString();
if (!path.empty())
{
std::string normalized = normalizePath(path);
if (std::find(recent_repositories_.begin(), recent_repositories_.end(), normalized) == recent_repositories_.end())
recent_repositories_.emplace_back(std::move(normalized));
}
}
}
if (root.isMember("session") && root["session"].isObject())
{
const ikv::Value &session = root["session"];
if (session.isMember("active_tab") && session["active_tab"].isInt())
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, session["active_tab"].asInt64()));
if (session.isMember("tabs") && session["tabs"].isArray())
{
const ikv::Value &tabs = session["tabs"];
for (std::size_t i = 0; i < tabs.size(); ++i)
open_repositories_.emplace_back(tabs[static_cast<ArrayIndex>(i)].asString());
}
}
if (root.isMember("repository_settings") && root["repository_settings"].isArray())
{
const ikv::Value &repos = root["repository_settings"];
for (std::size_t i = 0; i < repos.size(); ++i)
{
const ikv::Value &entry = repos[static_cast<ArrayIndex>(i)];
if (!entry.isObject() || !entry.isMember("path"))
continue;
const std::string path_str = entry["path"].asString();
if (path_str.empty())
continue;
const std::string key = normalizePath(path_str);
RepoSettings &rs = repo_settings_[key];
// column widths
rs.column_widths = commit_table_column_widths_; // start from global default
if (entry.isMember("commit_table_columns") && entry["commit_table_columns"].isArray())
{
const ikv::Value &widths = entry["commit_table_columns"];
const auto count = std::min<std::size_t>(widths.size(), 4);
for (std::size_t c = 0; c < count; ++c)
rs.column_widths[c] = static_cast<float>(widths[static_cast<ArrayIndex>(c)].asDouble());
}
if (entry.isMember("commit_summary") && entry["commit_summary"].isString())
rs.pending_commit_summary = entry["commit_summary"].asString();
if (entry.isMember("commit_description") && entry["commit_description"].isString())
rs.pending_commit_description = entry["commit_description"].asString();
}
}
if (root.isMember("credentials") && root["credentials"].isArray())
{
const ikv::Value &creds = root["credentials"];
for (std::size_t i = 0; i < creds.size(); ++i)
{
const ikv::Value &entry = creds[static_cast<ArrayIndex>(i)];
if (!entry.isObject())
continue;
if (!entry.isMember("scope") || !entry.isMember("secret"))
continue;
const std::string scope = entry["scope"].asString();
const std::string secret = entry["secret"].asString();
if (!scope.empty() && !secret.empty())
encrypted_credentials_[scope] = secret;
}
}
// Clamp loaded values.
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
commit_message_height_ = std::clamp(commit_message_height_, 60.0f, 600.0f);
working_composer_height_ = std::clamp(working_composer_height_, 100.0f, 900.0f);
for (float &h : sidebar_section_heights_)
h = std::clamp(h, 42.0f, 500.0f);
for (float &w : commit_table_column_widths_)
w = std::clamp(w, 0.0f, 1200.0f);
for (auto &[repo_key, rs] : repo_settings_)
for (float &w : rs.column_widths)
w = std::clamp(w, 0.0f, 1200.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
return;
}
const std::filesystem::path text_data_path = directory_ / "user_data.ikv";
if (ikv_node_t *root = ikv_parse_file(text_data_path.string().c_str()))
catch (const ikv::Error &)
{
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
{
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
}
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(history, index));
if (path && *path)
recently_closed_.emplace_back(path);
}
}
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(recent, index));
if (path && *path)
recent_repositories_.emplace_back(path);
}
}
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
{
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
{
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
{
const char *path = ikv_as_string(ikv_array_get(tabs, index));
open_repositories_.emplace_back(path ? path : "");
}
}
}
ikv_free(root);
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
save();
return;
// File missing or corrupt — try legacy paths below.
}
// Import the original files once when upgrading an existing installation.
// ── Legacy text load (user_data.ikv — old text format) ───────────────────
// (The old binary was user_data.ikv2b; user_data.ikv was the text file.)
// We'll silently skip if missing.
const std::filesystem::path text_path = directory_ / "user_data_legacy.ikv";
// (No legacy text migration needed — just fall through to defaults.)
// ── Import from very old settings.ini ────────────────────────────────────
std::ifstream settings(directory_ / "settings.ini");
std::string key;
while (settings >> key)
{
if (key == "sidebar_width")
settings >> sidebar_width_;
else if (key == "details_width")
settings >> details_width_;
else if (key == "pull_mode")
settings >> pull_mode_;
else if (key == "zoom_percent")
settings >> zoom_percent_;
if (key == "sidebar_width") settings >> sidebar_width_;
else if (key == "details_width") settings >> details_width_;
else if (key == "pull_mode") settings >> pull_mode_;
else if (key == "zoom_percent") settings >> zoom_percent_;
else if (key.rfind("sidebar_section_", 0) == 0)
{
const size_t index = static_cast<size_t>(std::stoul(key.substr(16)));
@@ -215,41 +407,81 @@ void UserData::load()
sidebar_section_heights_[index] = height;
}
}
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
std::ifstream history(directory_ / "history.txt");
std::string path;
while (history >> std::quoted(path))
{
if (!path.empty())
recently_closed_.push_back(path);
{
std::string normalized = normalizePath(path);
if (std::find(recently_closed_.begin(), recently_closed_.end(), normalized) == recently_closed_.end())
recently_closed_.push_back(std::move(normalized));
}
if (recently_closed_.size() == 100)
break;
}
recent_repositories_ = recently_closed_;
std::ifstream session(directory_ / "session.txt");
session >> active_repository_;
while (session >> std::quoted(path))
open_repositories_.push_back(path);
std::ifstream session_file(directory_ / "session.txt");
session_file >> active_repository_;
while (session_file >> std::quoted(path))
{
if (!path.empty())
open_repositories_.push_back(normalizePath(path));
}
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &h : sidebar_section_heights_)
h = std::clamp(h, 42.0f, 500.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
save();
save(); // migrate to new format immediately
}
std::optional<SavedGitCredential> UserData::remoteCredential(const std::string &remote_url) const
{
const auto iterator = encrypted_credentials_.find(credentialScope(remote_url));
if (iterator == encrypted_credentials_.end())
return std::nullopt;
#ifdef _WIN32
const auto plain = unprotectCredentialString(iterator->second);
if (!plain)
return std::nullopt;
const auto split = splitCredentialPayload(*plain);
if (!split)
return std::nullopt;
return SavedGitCredential{remote_url, split->first, split->second};
#else
return std::nullopt;
#endif
}
std::string UserData::pendingCommitSummary(const std::string &repo_path) const
{
const std::string key = normalizePath(repo_path);
const RepoSettings *rs = findRepoSettings(key);
return rs ? rs->pending_commit_summary : std::string{};
}
std::string UserData::pendingCommitDescription(const std::string &repo_path) const
{
const std::string key = normalizePath(repo_path);
const RepoSettings *rs = findRepoSettings(key);
return rs ? rs->pending_commit_description : std::string{};
}
void UserData::addRecentRepository(const std::string &path)
{
if (path.empty())
return;
std::erase(recent_repositories_, path);
recent_repositories_.insert(recent_repositories_.begin(), path);
if (path.empty()) return;
const std::string normalized = normalizePath(path);
std::erase(recent_repositories_, normalized);
recent_repositories_.insert(recent_repositories_.begin(), normalized);
if (recent_repositories_.size() > 100)
recent_repositories_.resize(100);
save();
@@ -257,14 +489,14 @@ void UserData::addRecentRepository(const std::string &path)
void UserData::addRecentlyClosed(const std::string &path)
{
if (path.empty())
return;
std::erase(recent_repositories_, path);
recent_repositories_.insert(recent_repositories_.begin(), path);
if (path.empty()) return;
const std::string normalized = normalizePath(path);
std::erase(recent_repositories_, normalized);
recent_repositories_.insert(recent_repositories_.begin(), normalized);
if (recent_repositories_.size() > 100)
recent_repositories_.resize(100);
std::erase(recently_closed_, path);
recently_closed_.insert(recently_closed_.begin(), path);
std::erase(recently_closed_, normalized);
recently_closed_.insert(recently_closed_.begin(), normalized);
if (recently_closed_.size() > 100)
recently_closed_.resize(100);
save();
@@ -272,8 +504,7 @@ void UserData::addRecentlyClosed(const std::string &path)
std::string UserData::takeRecentlyClosed()
{
if (recently_closed_.empty())
return {};
if (recently_closed_.empty()) return {};
std::string path = std::move(recently_closed_.front());
recently_closed_.erase(recently_closed_.begin());
save();
@@ -282,6 +513,8 @@ std::string UserData::takeRecentlyClosed()
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository)
{
for (auto &path : paths)
path = normalizePath(path);
open_repositories_ = std::move(paths);
active_repository_ = open_repositories_.empty()
? 0
@@ -289,37 +522,124 @@ void UserData::setRepositorySession(std::vector<std::string> paths, size_t activ
save();
}
void UserData::storeRemoteCredential(const std::string &remote_url, const std::string &username,
const std::string &password)
{
const std::string scope = credentialScope(remote_url);
if (scope.empty() || username.empty()) return;
#ifdef _WIN32
const auto protected_value = protectCredentialString(username + "\n" + password);
if (!protected_value) return;
encrypted_credentials_[scope] = *protected_value;
save();
#else
(void)password;
#endif
}
void UserData::clearRemoteCredential(const std::string &remote_url)
{
encrypted_credentials_.erase(credentialScope(remote_url));
save();
}
void UserData::setPendingCommitSummary(const std::string &repo_path, const std::string &text)
{
repoSettings(normalizePath(repo_path)).pending_commit_summary = text;
// Don't trigger a full save on every keystroke — caller must call save() when appropriate.
}
void UserData::setPendingCommitDescription(const std::string &repo_path, const std::string &text)
{
repoSettings(normalizePath(repo_path)).pending_commit_description = text;
}
void UserData::save() const
{
std::filesystem::create_directories(directory_);
ikv_node_t *root = ikv_create_object("gitree");
if (!root)
return;
ikv::Value root(ikv::objectValue, "gitree");
ikv_node_t *settings = ikv_object_add_object(root, "settings");
ikv_object_set_float(settings, "sidebar_width", sidebar_width_);
ikv_object_set_float(settings, "details_width", details_width_);
ikv_object_set_int(settings, "pull_mode", pull_mode_);
ikv_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);
auto settings = root.makeObject("settings");
settings["sidebar_width"] = static_cast<double>(sidebar_width_);
settings["details_width"] = static_cast<double>(details_width_);
settings["sidebar_collapsed"] = sidebar_collapsed_;
settings["commit_message_height"] = static_cast<double>(commit_message_height_);
settings["working_composer_height"] = static_cast<double>(working_composer_height_);
settings["pull_mode"] = static_cast<int64_t>(pull_mode_);
settings["zoom_percent"] = static_cast<int64_t>(zoom_percent_);
ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
for (const auto &path : recently_closed_)
ikv_array_add_string(history, path.c_str());
auto heights = settings.makeArray("sidebar_sections", ikv::realValue);
for (const float h : sidebar_section_heights_)
heights.append(static_cast<double>(h));
ikv_node_t *recent = ikv_object_add_array(root, "recent_repositories", IKV_STRING);
for (const auto &path : recent_repositories_)
ikv_array_add_string(recent, path.c_str());
auto open = settings.makeArray("sidebar_section_open", ikv::booleanValue);
for (const bool b : sidebar_section_open_)
open.append(b);
ikv_node_t *session = ikv_object_add_object(root, "session");
ikv_object_set_int(session, "active_tab", static_cast<int64_t>(active_repository_));
ikv_node_t *tabs = ikv_object_add_array(session, "tabs", IKV_STRING);
for (const auto &path : open_repositories_)
ikv_array_add_string(tabs, path.c_str());
auto col_widths = settings.makeArray("commit_table_columns", ikv::realValue);
for (const float w : commit_table_column_widths_)
col_widths.append(static_cast<double>(w));
ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2);
ikv_free(root);
auto repos = root.makeArray("repository_settings", ikv::objectValue);
for (const auto &[repo_path, rs] : repo_settings_)
{
auto entry = repos.appendObject();
entry["path"] = repo_path;
auto rw = entry.makeArray("commit_table_columns", ikv::realValue);
for (const float w : rs.column_widths)
rw.append(static_cast<double>(w));
if (!rs.pending_commit_summary.empty())
entry["commit_summary"] = rs.pending_commit_summary;
if (!rs.pending_commit_description.empty())
entry["commit_description"] = rs.pending_commit_description;
}
auto history = root.makeArray("recently_closed", ikv::stringValue);
for (const auto &p : recently_closed_)
history.append(p);
auto recent = root.makeArray("recent_repositories", ikv::stringValue);
for (const auto &p : recent_repositories_)
recent.append(p);
auto session = root.makeObject("session");
session["active_tab"] = static_cast<int64_t>(active_repository_);
auto tabs = session.makeArray("tabs", ikv::stringValue);
for (const auto &p : open_repositories_)
tabs.append(p);
auto credentials = root.makeArray("credentials", ikv::objectValue);
for (const auto &[scope, secret] : encrypted_credentials_)
{
auto entry = credentials.appendObject();
entry["scope"] = scope;
entry["secret"] = secret;
}
root.writeBinary(dataPath().string(), ikv::Version::v2);
removeLegacyFiles();
}
float UserData::commitTableColumnWidth(const std::string &repo_path, size_t index) const
{
if (index >= 4) return 0.0f;
const std::string normalized = normalizePath(repo_path);
const RepoSettings *rs = findRepoSettings(normalized);
if (rs) return rs->column_widths[index];
return commit_table_column_widths_[index];
}
void UserData::setCommitTableColumnWidth(const std::string &repo_path, size_t index, float width)
{
if (index >= 4) return;
const std::string normalized = normalizePath(repo_path);
RepoSettings &rs = repo_settings_[normalized];
// Initialise from global defaults if this is a brand-new entry.
if (rs.column_widths[0] == 0.0f && rs.column_widths[1] == 0.0f &&
rs.column_widths[2] == 0.0f && rs.column_widths[3] == 0.0f)
{
rs.column_widths = commit_table_column_widths_;
}
rs.column_widths[index] = width;
save();
}

View File

@@ -2,9 +2,18 @@
#include <array>
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
struct SavedGitCredential
{
std::string remote_url;
std::string username;
std::string password;
};
class UserData
{
public:
@@ -19,12 +28,28 @@ 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 commitMessageHeight() const { return commit_message_height_; }
[[nodiscard]] float workingComposerHeight() const { return working_composer_height_; }
[[nodiscard]] float commitTableColumnWidth(const std::string &repo_path, size_t index) const;
[[nodiscard]] int pullMode() const { return pull_mode_; }
[[nodiscard]] int zoomPercent() const { return zoom_percent_; }
[[nodiscard]] std::optional<SavedGitCredential> remoteCredential(const std::string &remote_url) const;
[[nodiscard]] std::string pendingCommitSummary(const std::string &repo_path) const;
[[nodiscard]] std::string pendingCommitDescription(const std::string &repo_path) const;
static std::string credentialScope(const std::string &remote_url);
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 setCommitMessageHeight(float height) { commit_message_height_ = height; }
void setWorkingComposerHeight(float height) { working_composer_height_ = height; }
void setCommitTableColumnWidth(const std::string &repo_path, size_t index, float width);
void setPendingCommitSummary(const std::string &repo_path, const std::string &text);
void setPendingCommitDescription(const std::string &repo_path, const std::string &text);
void setPullMode(int mode)
{
pull_mode_ = mode;
@@ -39,12 +64,24 @@ public:
void addRecentlyClosed(const std::string &path);
std::string takeRecentlyClosed();
void setRepositorySession(std::vector<std::string> paths, size_t active_repository);
void storeRemoteCredential(const std::string &remote_url, const std::string &username,
const std::string &password);
void clearRemoteCredential(const std::string &remote_url);
void save() const;
private:
struct RepoSettings
{
std::array<float, 4> column_widths = {0.0f, 0.0f, 0.0f, 0.0f};
std::string pending_commit_summary;
std::string pending_commit_description;
};
void load();
[[nodiscard]] std::filesystem::path dataPath() const;
void removeLegacyFiles() const;
RepoSettings &repoSettings(const std::string &normalized_path);
const RepoSettings *findRepoSettings(const std::string &normalized_path) const;
std::filesystem::path directory_;
std::vector<std::string> recent_repositories_;
@@ -54,6 +91,13 @@ private:
float sidebar_width_ = 230.0f;
float details_width_ = 368.0f;
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
bool sidebar_collapsed_ = false;
std::array<bool, 4> sidebar_section_open_ = {true, true, true, true};
float commit_message_height_ = 125.0f;
float working_composer_height_ = 320.0f;
std::array<float, 4> commit_table_column_widths_ = {0.0f, 0.0f, 0.0f, 0.0f};
int pull_mode_ = 1;
int zoom_percent_ = 100;
std::unordered_map<std::string, RepoSettings> repo_settings_;
std::unordered_map<std::string, std::string> encrypted_credentials_;
};

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;

File diff suppressed because it is too large Load Diff

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,15 @@ 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_;
bool line_wrap_ = false;
bool scroll_to_top_ = false;
bool minimap_drag_active_ = false;
float minimap_drag_offset_ = 0.0f;
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);
};

File diff suppressed because it is too large Load Diff

View File

@@ -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 <size_t Count>
void drawRoundedPolyline(ImDrawList* draw, const std::array<ImVec2, Count>& points,
float radius, ImU32 color, float thickness) {
@@ -64,16 +74,20 @@ void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& par
return;
}
// Offset detour route lines horizontally by slot to avoid lane collisions
const float slot_offset = static_cast<float>(route_slot) * 4.0f * scale;
if (std::isfinite(detour_x)) {
if (std::abs(detour_x - child.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, parent.y}, parent},
const float final_detour_x = detour_x + slot_offset;
if (std::abs(final_detour_x - child.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{final_detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
} else if (std::abs(detour_x - parent.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, child.y}, parent},
} else if (std::abs(final_detour_x - parent.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{final_detour_x, child.y}, parent},
7.0f * scale, color, thickness);
} else {
drawRoundedPolyline(draw,
std::array{child, ImVec2{detour_x, child.y}, ImVec2{detour_x, parent.y}, parent},
std::array{child, ImVec2{final_detour_x, child.y}, ImVec2{final_detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
}
return;
@@ -114,7 +128,7 @@ float GraphRenderer::requiredWidth(const std::vector<CommitInfo>& commits, float
int maximum_lane = 0;
for (const auto& commit : commits) maximum_lane = std::max(maximum_lane, commit.lane);
// Keep one spare lane available for edges that must route around occupied commit lanes.
return std::max((68.0f + maximum_lane * 22.0f) * scale, 82.0f * scale);
return std::max((68.0f + maximum_lane * 28.0f) * scale, 82.0f * scale);
}
void GraphRenderer::drawRow(int row, const CommitInfo& commit,
@@ -125,7 +139,7 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float row_height = row_heights[static_cast<size_t>(row)];
const float content_height = std::max(px(1.0f), row_height - ImGui::GetStyle().CellPadding.y * 2.0f);
const float lane_spacing = px(22.0f);
const float lane_spacing = px(28.0f);
const float x = origin.x + px(17.0f) + lane_spacing * commit.lane;
const float y = origin.y + content_height * 0.5f;
const float cell_right = origin.x + ImGui::GetContentRegionAvail().x;
@@ -164,64 +178,99 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
(row_heights[static_cast<size_t>(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<RoutedEdge> 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<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(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<int>(commits.size()); ++child_row) {
const auto& child_parents = parent_rows[static_cast<size_t>(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<size_t>(child_row)];
const CommitInfo& parent_commit = commits[static_cast<size_t>(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<int>(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<int>(commits.size()); ++child_row) {
if (row_heights[static_cast<size_t>(child_row)] <= 0.0f) continue;
const CommitInfo& child = commits[static_cast<size_t>(child_row)];
const auto& child_parents = parent_rows[static_cast<size_t>(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<size_t>(parent_row)] <= 0.0f) continue;
const CommitInfo& parent = commits[static_cast<size_t>(parent_row)];
for (const RoutedEdge& route : routed_edges) {
if (row < route.child_row || row > route.parent_row ||
row_heights[static_cast<size_t>(route.child_row)] <= 0.0f ||
row_heights[static_cast<size_t>(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<float>::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<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(intermediate)].lane == candidate_lane)
return true;
}
return false;
};
float detour_x = std::numeric_limits<float>::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<size_t>(intermediate)] > 0.0f)
maximum_lane = std::max(maximum_lane,
commits[static_cast<size_t>(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<int>(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<int>(parent_index), detour_x);
}
laneColor(route.color_lane, 235), scale_, route.route_slot, detour_x);
}
draw->PopClipRect();

1
vendor/iKv vendored

Submodule vendor/iKv deleted from d0b02f4735

1
vendor/iKvxx vendored Submodule

Submodule vendor/iKvxx added at 99d3ea7b83

View File

@@ -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"