104 Commits

Author SHA1 Message Date
f0fce51efc Merge branch 'main' into prod
All checks were successful
Build Releases / Build Linux x64 DEB (push) Successful in 3m51s
Build Releases / Build Windows x64_86 (push) Successful in 7m15s
Build Releases / Create Release (push) Successful in 1m51s
2026-06-19 19:23:26 -05:00
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
2e38d51f74 Merge branch 'main' into prod
All checks were successful
Build Windows EXE / build-windows (push) Successful in 7m51s
2026-06-18 23:38:05 -05:00
755092933f new build workflow
All checks were successful
Build Windows EXE / build-windows (push) Successful in 8m18s
2026-06-18 23:37:44 -05:00
a5b0e02387 feat(commits): add commit context actions 2026-06-18 23:37:18 -05:00
ee7efa6eff fix(graph): align selected row overlay to commit columns
Some checks failed
Build Windows EXE / build-windows (push) Has been cancelled
2026-06-18 23:32:10 -05:00
303d8469a0 fix(files): pin staged section headers while scrolling 2026-06-18 23:31:01 -05:00
7989224a8c fix(files): stabilize row layout on hover actions 2026-06-18 23:29:58 -05:00
106a2b8cf9 fix(graph): tone down pending change counters 2026-06-18 23:29:03 -05:00
8368b0e237 fix(toolbar): emphasize icons over labels 2026-06-18 23:27:46 -05:00
942e95e7a1 fix(sidebar): expand active branch highlight 2026-06-18 23:26:38 -05:00
0425bb7320 feat(files): add extension badges for common file types 2026-06-18 23:25:49 -05:00
508c0f037e fix(window): match caption icon to taskbar logo 2026-06-18 23:24:30 -05:00
e1107756f9 refactor(settings): consolidate appdata state into ikv2b 2026-06-18 23:23:48 -05:00
e3b6c34e88 feat(recent): persist dynamic repository history 2026-06-18 23:21:59 -05:00
57d7be3357 fix(ui): soften dpi scaling and normalize commit table text 2026-06-18 23:19:47 -05:00
2d451a5ece feat(toolbar): show dynamic undo and redo actions 2026-06-18 23:18:24 -05:00
a64770b684 fix(files): center path and tree toggles 2026-06-18 23:14:41 -05:00
009a4efb87 fix(diff): restore syntax rendering and minimap sizing 2026-06-18 23:13:43 -05:00
b01bbd2b73 fix(files): use correct tree toggle icon 2026-06-18 23:11:05 -05:00
b100b19b16 fix(branches): show feedback while switching 2026-06-18 23:10:22 -05:00
dff02adc45 fix(toolbar): show progress state for pull and push 2026-06-18 23:06:50 -05:00
cdea4503c7 fix(index): preserve selection on bulk stage operations 2026-06-18 23:05:00 -05:00
a8bec3ed22 feat(ui): refine graph, viewer, and repository workflows 2026-06-18 23:03:20 -05:00
fefc2084ac chore(deps): update bundled submodules
All checks were successful
Build Windows EXE / build-windows (push) Successful in 5m45s
2026-06-18 21:25:56 -05:00
0152667b65 feat(app): polish repository workflows and graph 2026-06-18 21:25:51 -05:00
5133418bfd Merge pull request 'main' (#2) from main into prod
All checks were successful
Build Windows EXE / build-windows (push) Successful in 23m29s
Reviewed-on: #2
2026-06-19 02:18:50 +00:00
084b707776 docs(readme): update usage and build guide
All checks were successful
Build Windows EXE / build-windows (push) Successful in 5m44s
2026-06-18 21:09:22 -05:00
90937265dd feat(git): integrate Git Credential Manager 2026-06-18 20:09:58 -05:00
17028fafdd refactor(sidebar): unify repository sections 2026-06-18 20:07:58 -05:00
54f91349c4 refactor(ui): remove AI commit controls 2026-06-18 20:06:22 -05:00
ebcdea8c97 fix(refs): collapse duplicate upstream branch chips 2026-06-18 20:05:08 -05:00
cd1072362f fix(history): select commits from message cells 2026-06-18 20:03:48 -05:00
23a60061f6 feat(toolbar): add external application launcher 2026-06-18 20:02:49 -05:00
9c64c74f86 fix(sidebar): align section add buttons and counts 2026-06-18 19:59:23 -05:00
4573aa0bb5 fix(sidebar): constrain section resizing to panel floor 2026-06-18 19:58:10 -05:00
ff63594f8a fix(refs): show icons on every local branch chip 2026-06-18 19:56:39 -05:00
2636fe30a9 feat(branch): create branches inline in ref column 2026-06-18 19:56:07 -05:00
38 changed files with 8045 additions and 1354 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:
@@ -14,6 +18,23 @@ jobs:
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_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: |
@@ -25,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)
@@ -38,46 +62,339 @@ jobs:
run: |
cmake -S . -B build-win -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=mingw-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DCMAKE_EXE_LINKER_FLAGS="-static -static-libgcc -static-libstdc++"
- name: Build Windows executable
run: cmake --build build-win --parallel
- name: Prepare artifact
- 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 "$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
echo "The executable still depends on a MinGW runtime DLL."
exit 1
fi
- name: Prepare Windows artifact
run: |
mkdir -p dist
cp build-win/bin/gitree.exe dist/Gitree-windows-x64.exe
cp "$WINDOWS_EXE_PATH" "dist/${WINDOWS_EXE}"
- name: Upload Windows build
uses: actions/upload-artifact@v3
with:
name: Gitree-windows-x64
path: dist/Gitree-windows-x64.exe
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."
exit 1
fi
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
tag="prod-${GITEA_RUN_NUMBER}-${short_sha}"
git fetch --tags --force
api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}"
repo_url="${GITEA_SERVER_URL%/}/${GITEA_REPOSITORY}"
previous_tag="$(
curl --fail-with-body --silent --show-error \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api}/releases?limit=50" |
jq -r '[.[] | select(.tag_name | startswith("release-"))][0].tag_name // empty'
)"
if [ -n "$previous_tag" ]; then
range="${previous_tag}..${GITEA_SHA}"
else
range="${GITEA_SHA}"
fi
{
if [ -n "$previous_tag" ]; then
echo "## Changes since ${previous_tag}"
else
echo "## Changes"
fi
echo
commit_count="$(git rev-list --count "$range")"
if [ "$commit_count" -eq 0 ]; then
echo "- No commits found since the previous release."
else
git log "$range" \
--reverse \
--pretty=format:'%H%x1f%s' |
while IFS="$(printf '\037')" read -r hash subject; do
short="$(printf '%s' "$hash" | cut -c1-7)"
echo "- ([${short}](${repo_url}/commit/${hash})) ${subject}"
done
fi
echo
echo "## 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
echo "Sorted by total lines added or removed."
echo
git log "$range" --numstat --format='author:%an <%ae>' |
awk '
/^author:/ {
author = substr($0, 8)
next
}
NF >= 3 {
added = ($1 == "-" ? 0 : $1)
removed = ($2 == "-" ? 0 : $2)
adds[author] += added
dels[author] += removed
churn[author] += added + removed
}
END {
for (a in churn) {
printf "%d\t%d\t%d\t%s\n", churn[a], adds[a], dels[a], a
}
}
' |
sort -nr |
while IFS="$(printf '\t')" read -r total added removed author; do
echo "- ${author} — ${total} lines changed (+${added} / -${removed})"
done
} > release-notes.md
payload="$(jq -n \
--arg tag "$tag" \
--arg tag "$RELEASE_TAG" \
--arg sha "$GITEA_SHA" \
--arg name "Gitree ${tag}" \
'{tag_name: $tag, target_commitish: $sha, name: $name, body: "Automated Windows release from the prod branch.", draft: false, prerelease: false}')"
--arg name "$RELEASE_NAME" \
--rawfile body release-notes.md \
'{
tag_name: $tag,
target_commitish: $sha,
name: $name,
body: $body,
draft: false,
prerelease: false
}')"
release="$(curl --fail-with-body --silent --show-error \
-X POST \
@@ -85,10 +402,21 @@ jobs:
-H "Content-Type: application/json" \
--data "$payload" \
"${api}/releases")"
release_id="$(printf '%s' "$release" | jq -er '.id')"
curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/Gitree-windows-x64.exe" \
"${api}/releases/${release_id}/assets?name=Gitree-windows-x64.exe"
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)
@@ -70,22 +72,27 @@ add_executable(gitree WIN32
src/managers/user_data.h
src/managers/avatar_cache.cpp
src/managers/avatar_cache.h
src/managers/application_manager.cpp
src/managers/application_manager.h
src/managers/application_icon_cache.cpp
src/managers/application_icon_cache.h
src/models/repository.h
)
target_include_directories(gitree PRIVATE src vendor/libgit2/include vendor/icons)
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"
GITREE_IMAGE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
$<$<PLATFORM_ID:Windows>:NOMINMAX;WIN32_LEAN_AND_MEAN>
)
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()

107
README.md
View File

@@ -1,42 +1,97 @@
# Gitree
![Gitree banner](.gitea/images/gitree_banner_rounded.png)
A native Dear ImGui Git client built with libgit2 and GLFW.
[![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)
[![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)
## Build on Windows
A fast, native Git desktop client for Windows.
Clone with submodules, then run `run.bat`. The script configures a Release build,
builds Gitree and launches it. You can pass a repository path to open it directly:
## Download
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).
No installer is required: download the executable and run it.
## Use Gitree
Open Gitree, choose **Open repository**, and select a local Git repository. You can
also pass its path when launching from a terminal:
```powershell
.\Gitree-windows-x64_86.exe C:\path\to\repository
```
Use the sidebar to switch branches and browse refs. Select a commit to inspect its
details and changes. The commit area lets you stage files and create commits.
## Build from source
### Requirements
- 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++**
workload, or a recent MinGW-w64 toolchain
- Ninja is optional; `run.bat` uses it automatically when available
Clone the repository with all submodules:
```powershell
git clone --recurse-submodules https://dock-it.dev/Idea-Studios/Gitree.git
cd Gitree
```
If the repository was cloned without `--recurse-submodules`, initialize the
dependencies before configuring the build:
```powershell
git submodule update --init --recursive
```
### Build and run automatically
From a Developer PowerShell or Command Prompt:
```bat
run.bat
```
`run.bat` configures a Release build, builds Gitree, and launches it. Pass a
repository path to open it immediately:
```bat
run.bat C:\path\to\repository
```
Manual build:
### Build with Visual Studio 2022
```bat
git submodule update --init --recursive
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
```powershell
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
cmake --build build --config Release --parallel
.\build\bin\Release\gitree.exe
```
Vendored dependencies are pinned under `vendor/` and compiled as static libraries:
### Build with Ninja
- libgit2 1.9.4
- Dear ImGui 1.92.8 (docking branch)
- GLFW 3.4
- iZo 0.1.0
Run these commands in a shell where your compiler is available. For MSVC, use a
Visual Studio Developer PowerShell or Developer Command Prompt.
The bundled Inter variable font is licensed under the SIL Open Font License.
Font Awesome Free icons are bundled from Attascii and merged into the ImGui font atlas.
```powershell
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
.\build\bin\gitree.exe
```
User settings, ImGui layout state, and recently closed repository history are stored in
`%APPDATA%\Identity\Gitree` on Windows. The directory is created automatically.
Commit avatars are resolved from normalized author emails through Gravatar and cached in
the `avatars` subdirectory; an identicon is used when an account has no custom image.
## License
## Source layout
Gitree is licensed under the [Creative Commons Attribution-ShareAlike 4.0
International license](https://dock-it.dev/Idea-Studios/Gitree/src/branch/prod/LICENSE).
- `src/managers/` owns Git, window, user-data, and avatar services.
- `src/models/` contains repository and commit data models.
- `src/ui/` contains the Dear ImGui application interface.
- `src/main.cpp` is the minimal process entry point.
## 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

@@ -0,0 +1,97 @@
#include "application_icon_cache.h"
#include <GLFW/glfw3.h>
#include <algorithm>
#include <array>
#ifdef _WIN32
#include <windows.h>
#include <shellapi.h>
#endif
ApplicationIconCache::~ApplicationIconCache()
{
shutdown();
}
unsigned int ApplicationIconCache::textureFor(const std::filesystem::path &executable)
{
if (executable.empty())
return 0;
const std::string key = executable.string();
const auto found = textures_.find(key);
if (found != textures_.end())
return found->second;
const unsigned int texture = loadTexture(executable);
textures_.emplace(key, texture);
return texture;
}
unsigned int ApplicationIconCache::loadTexture(const std::filesystem::path &executable) const
{
#ifdef _WIN32
HICON large_icon = nullptr;
HICON small_icon = nullptr;
if (ExtractIconExW(executable.wstring().c_str(), 0, &large_icon, &small_icon, 1) == 0)
return 0;
HICON icon = large_icon ? large_icon : small_icon;
if (!icon)
return 0;
constexpr int size = 32;
BITMAPINFO bitmap_info{};
bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bitmap_info.bmiHeader.biWidth = size;
bitmap_info.bmiHeader.biHeight = -size;
bitmap_info.bmiHeader.biPlanes = 1;
bitmap_info.bmiHeader.biBitCount = 32;
bitmap_info.bmiHeader.biCompression = BI_RGB;
void *pixels = nullptr;
HDC device = CreateCompatibleDC(nullptr);
HBITMAP bitmap = CreateDIBSection(device, &bitmap_info, DIB_RGB_COLORS, &pixels, nullptr, 0);
unsigned int texture = 0;
if (bitmap && pixels)
{
const HGDIOBJ previous = SelectObject(device, bitmap);
std::fill_n(static_cast<unsigned char *>(pixels), size * size * 4, 0);
if (DrawIconEx(device, 0, 0, icon, size, size, 0, nullptr, DI_NORMAL))
{
auto *rgba = static_cast<unsigned char *>(pixels);
for (int pixel = 0; pixel < size * size; ++pixel)
std::swap(rgba[pixel * 4], rgba[pixel * 4 + 2]);
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, size, size, 0,
GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glBindTexture(GL_TEXTURE_2D, 0);
}
SelectObject(device, previous);
}
if (bitmap)
DeleteObject(bitmap);
if (device)
DeleteDC(device);
if (large_icon)
DestroyIcon(large_icon);
if (small_icon)
DestroyIcon(small_icon);
return texture;
#else
(void)executable;
return 0;
#endif
}
void ApplicationIconCache::shutdown()
{
for (auto &[path, texture] : textures_)
{
(void)path;
if (texture)
glDeleteTextures(1, &texture);
}
textures_.clear();
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
class ApplicationIconCache
{
public:
~ApplicationIconCache();
ApplicationIconCache(const ApplicationIconCache &) = delete;
ApplicationIconCache &operator=(const ApplicationIconCache &) = delete;
ApplicationIconCache() = default;
unsigned int textureFor(const std::filesystem::path &executable);
void shutdown();
private:
unsigned int loadTexture(const std::filesystem::path &executable) const;
std::map<std::string, unsigned int> textures_;
};

View File

@@ -0,0 +1,552 @@
#include "managers/application_manager.h"
#include <izo/Environment.hpp>
#include <izo/Paths.hpp>
#include <izo/Process.hpp>
#include <algorithm>
namespace
{
std::filesystem::path environmentPath(const char *name)
{
const auto value = izo::GetEnvVar(name);
return value && !value->empty() ? std::filesystem::path(*value) : std::filesystem::path{};
}
std::filesystem::path 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;
iterator != end && !error; iterator.increment(error))
{
if (iterator->is_regular_file(error) && iterator->path().filename() == filename)
return iterator->path();
}
return {};
}
}
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 = {})
{
const bool available = !executable.empty();
targets_.push_back({{id, name, available, std::move(executable)}, std::move(arguments)});
applications_.push_back(targets_.back().info);
};
// -------------------------------------------------------------------------
// VS Code-style editors
// -------------------------------------------------------------------------
add(ExternalApplicationId::visual_studio_code, "VS Code", firstExisting({
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({
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({
under(local, "GitHubDesktop/GitHubDesktop.exe"),
under(local, "GitHub Desktop/GitHubDesktop.exe"),
});
if (github_desktop.empty())
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({
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({
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({
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({
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(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 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 available == targets_.end()
? ExternalApplicationId::file_explorer
: available->info.id;
}
bool ApplicationManager::launch(ExternalApplicationId application,
const std::filesystem::path &repository,
std::string &error) const
{
const auto found = std::find_if(targets_.begin(), targets_.end(),
[application](const LaunchTarget &target)
{
return target.info.id == application;
});
if (found == targets_.end() || !found->info.available)
{
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

@@ -0,0 +1,89 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
enum class ExternalApplicationId
{
visual_studio_code,
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
{
ExternalApplicationId id;
std::string name;
bool available = false;
std::filesystem::path executable;
};
class ApplicationManager
{
public:
ApplicationManager();
const std::vector<ExternalApplication> &applications() const { return applications_; }
const ExternalApplication *application(ExternalApplicationId id) const;
ExternalApplicationId defaultApplication() const;
bool launch(ExternalApplicationId application, const std::filesystem::path &repository,
std::string &error) const;
private:
struct LaunchTarget
{
ExternalApplication info;
std::vector<std::string> arguments;
};
std::vector<LaunchTarget> targets_;
std::vector<ExternalApplication> applications_;
};

View File

@@ -17,23 +17,31 @@
#include <wincodec.h>
#endif
AvatarCache::AvatarCache(const std::filesystem::path& user_data_directory)
: directory_(user_data_directory / "avatars") {
AvatarCache::AvatarCache(const std::filesystem::path &user_data_directory)
: directory_(user_data_directory / "avatars")
{
std::filesystem::create_directories(directory_);
}
AvatarCache::~AvatarCache() {
AvatarCache::~AvatarCache()
{
shutdown();
}
std::string AvatarCache::hashEmail(const std::string& email) const {
std::string AvatarCache::hashEmail(const std::string &email) const
{
std::string normalized = email;
normalized.erase(normalized.begin(), std::find_if(normalized.begin(), normalized.end(),
[](unsigned char value) { return !std::isspace(value); }));
[](unsigned char value)
{ return !std::isspace(value); }));
normalized.erase(std::find_if(normalized.rbegin(), normalized.rend(),
[](unsigned char value) { return !std::isspace(value); }).base(), normalized.end());
[](unsigned char value)
{ return !std::isspace(value); })
.base(),
normalized.end());
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
[](unsigned char value) { return static_cast<char>(std::tolower(value)); });
[](unsigned char value)
{ return static_cast<char>(std::tolower(value)); });
#ifdef _WIN32
BCRYPT_ALG_HANDLE algorithm = nullptr;
@@ -43,99 +51,121 @@ std::string AvatarCache::hashEmail(const std::string& email) const {
DWORD written = 0;
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_MD5_ALGORITHM, nullptr, 0) < 0 ||
BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast<PUCHAR>(&object_size),
sizeof(object_size), &written, 0) < 0) {
if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0);
sizeof(object_size), &written, 0) < 0)
{
if (algorithm)
BCryptCloseAlgorithmProvider(algorithm, 0);
return {};
}
std::vector<unsigned char> object(object_size);
if (BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) < 0 ||
BCryptHashData(hash, reinterpret_cast<PUCHAR>(normalized.data()),
static_cast<ULONG>(normalized.size()), 0) < 0 ||
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0) {
if (hash) BCryptDestroyHash(hash);
static_cast<ULONG>(normalized.size()), 0) < 0 ||
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) < 0)
{
if (hash)
BCryptDestroyHash(hash);
BCryptCloseAlgorithmProvider(algorithm, 0);
return {};
}
BCryptDestroyHash(hash);
BCryptCloseAlgorithmProvider(algorithm, 0);
std::ostringstream output;
for (unsigned char value : digest) output << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(value);
for (unsigned char value : digest)
output << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(value);
return output.str();
#else
return std::to_string(std::hash<std::string>{}(normalized));
#endif
}
unsigned int AvatarCache::textureFor(const std::string& email) {
if (email.empty()) return 0;
unsigned int AvatarCache::textureFor(const std::string &email)
{
if (email.empty())
return 0;
const std::string hash = hashEmail(email);
if (hash.empty()) return 0;
Entry& entry = entries_[hash];
if (entry.texture) return entry.texture;
if (entry.requested) return 0;
if (hash.empty())
return 0;
Entry &entry = entries_[hash];
if (entry.texture)
return entry.texture;
if (entry.requested)
return 0;
entry.requested = true;
entry.file = directory_ / (hash + ".img");
if (std::filesystem::exists(entry.file)) {
if (std::filesystem::exists(entry.file))
{
entry.texture = loadTexture(entry.file);
return entry.texture;
}
#ifdef _WIN32
const std::filesystem::path file = entry.file;
const std::wstring url = L"https://www.gravatar.com/avatar/" +
std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g";
entry.download = std::async(std::launch::async, [file, url] {
return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr));
});
std::wstring(hash.begin(), hash.end()) + L"?s=64&d=identicon&r=g";
entry.download = std::async(std::launch::async, [file, url]
{ return SUCCEEDED(URLDownloadToFileW(nullptr, url.c_str(), file.wstring().c_str(), 0, nullptr)); });
#endif
return 0;
}
void AvatarCache::update() {
for (auto& [hash, entry] : entries_) {
void AvatarCache::update()
{
for (auto &[hash, entry] : entries_)
{
(void)hash;
if (entry.texture || !entry.download.valid()) continue;
if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready) continue;
if (entry.download.get()) entry.texture = loadTexture(entry.file);
if (entry.texture || !entry.download.valid())
continue;
if (entry.download.wait_for(std::chrono::seconds(0)) != std::future_status::ready)
continue;
if (entry.download.get())
entry.texture = loadTexture(entry.file);
}
}
unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const {
unsigned int AvatarCache::loadTexture(const std::filesystem::path &file) const
{
#ifdef _WIN32
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
IWICImagingFactory* factory = nullptr;
IWICBitmapDecoder* decoder = nullptr;
IWICBitmapFrameDecode* frame = nullptr;
IWICFormatConverter* converter = nullptr;
IWICImagingFactory *factory = nullptr;
IWICBitmapDecoder *decoder = nullptr;
IWICBitmapFrameDecode *frame = nullptr;
IWICFormatConverter *converter = nullptr;
UINT width = 0;
UINT height = 0;
std::vector<unsigned char> pixels;
unsigned int texture = 0;
if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&factory))) &&
IID_PPV_ARGS(&factory))) &&
SUCCEEDED(factory->CreateDecoderFromFilename(file.wstring().c_str(), nullptr, GENERIC_READ,
WICDecodeMetadataCacheOnLoad, &decoder)) &&
WICDecodeMetadataCacheOnLoad, &decoder)) &&
SUCCEEDED(decoder->GetFrame(0, &frame)) &&
SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
SUCCEEDED(converter->Initialize(frame, GUID_WICPixelFormat32bppRGBA,
WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) &&
SUCCEEDED(converter->GetSize(&width, &height))) {
WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)) &&
SUCCEEDED(converter->GetSize(&width, &height)))
{
pixels.resize(static_cast<size_t>(width) * height * 4);
if (SUCCEEDED(converter->CopyPixels(nullptr, width * 4,
static_cast<UINT>(pixels.size()), pixels.data()))) {
static_cast<UINT>(pixels.size()), pixels.data())))
{
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, static_cast<int>(width), static_cast<int>(height),
0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
glBindTexture(GL_TEXTURE_2D, 0);
}
}
if (converter) converter->Release();
if (frame) frame->Release();
if (decoder) decoder->Release();
if (factory) factory->Release();
if (converter)
converter->Release();
if (frame)
frame->Release();
if (decoder)
decoder->Release();
if (factory)
factory->Release();
CoUninitialize();
return texture;
#else
@@ -144,11 +174,15 @@ unsigned int AvatarCache::loadTexture(const std::filesystem::path& file) const {
#endif
}
void AvatarCache::shutdown() {
for (auto& [hash, entry] : entries_) {
void AvatarCache::shutdown()
{
for (auto &[hash, entry] : entries_)
{
(void)hash;
if (entry.download.valid()) entry.download.wait();
if (entry.texture) glDeleteTextures(1, &entry.texture);
if (entry.download.valid())
entry.download.wait();
if (entry.texture)
glDeleteTextures(1, &entry.texture);
entry.texture = 0;
}
entries_.clear();

View File

@@ -5,28 +5,30 @@
#include <map>
#include <string>
class AvatarCache {
class AvatarCache
{
public:
explicit AvatarCache(const std::filesystem::path& user_data_directory);
explicit AvatarCache(const std::filesystem::path &user_data_directory);
~AvatarCache();
AvatarCache(const AvatarCache&) = delete;
AvatarCache& operator=(const AvatarCache&) = delete;
AvatarCache(const AvatarCache &) = delete;
AvatarCache &operator=(const AvatarCache &) = delete;
unsigned int textureFor(const std::string& email);
unsigned int textureFor(const std::string &email);
void update();
void shutdown();
private:
struct Entry {
struct Entry
{
unsigned int texture = 0;
bool requested = false;
std::filesystem::path file;
std::future<bool> download;
};
std::string hashEmail(const std::string& email) const;
unsigned int loadTexture(const std::filesystem::path& file) const;
std::string hashEmail(const std::string &email) const;
unsigned int loadTexture(const std::filesystem::path &file) const;
std::filesystem::path directory_;
std::map<std::string, Entry> entries_;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,91 @@
#pragma once
#pragma once
#include "models/repository.h"
#include <optional>
#include <string>
#include <vector>
class GitManager {
struct GitAuthOverride
{
std::string remote_url;
std::string username;
std::string password;
};
class GitManager
{
public:
GitManager();
~GitManager();
bool openRepository(RepositoryView& repository, const std::string& path, std::string& error);
bool initializeRepository(RepositoryView& repository, const std::string& path, std::string& error);
bool reload(RepositoryView& repository, std::string& error);
bool loadMoreCommits(RepositoryView& repository, size_t page_size, std::string& error);
bool checkoutBranch(RepositoryView& repository, const std::string& branch, std::string& error);
bool fetch(RepositoryView& repository, const std::string& remote, std::string& error);
bool pull(RepositoryView& repository, int mode, std::string& error);
bool push(RepositoryView& repository, std::string& error);
bool stash(RepositoryView& repository, std::string& error);
bool popStash(RepositoryView& repository, std::string& error);
bool undoCommit(RepositoryView& repository, std::string& error);
bool redoCommit(RepositoryView& repository, std::string& error);
bool stageAll(RepositoryView& repository, std::string& error);
bool stageFile(RepositoryView& repository, const std::string& path, std::string& error);
bool unstageFile(RepositoryView& repository, const std::string& path, std::string& error);
bool discardFile(RepositoryView& repository, const std::string& path, std::string& error);
bool discardAll(RepositoryView& repository, std::string& error);
bool commit(RepositoryView& repository, const std::string& summary,
const std::string& description, bool amend, std::string& error);
bool createBranch(RepositoryView& repository, const std::string& name,
const std::string& start_point, bool checkout, std::string& error);
bool createTag(RepositoryView& repository, const std::string& name,
const std::string& target, std::string& error);
bool addRemote(RepositoryView& repository, const std::string& name,
const std::string& url, std::string& error);
bool addWorktree(RepositoryView& repository, const std::string& path,
const std::string& branch, std::string& error);
bool addSubmodule(RepositoryView& repository, const std::string& url,
const std::string& path, std::string& error);
bool updateSubmodule(RepositoryView& repository, const std::string& name, std::string& error);
std::string worktreePath(RepositoryView& repository, const std::string& name, std::string& error);
bool loadCommitChanges(RepositoryView& repository, int commit_index, std::string& error);
bool captureGit(RepositoryView& repository, const std::vector<std::string>& arguments,
std::string& output, std::string& error);
bool applyPatch(RepositoryView& repository, const std::string& patch, bool cached,
bool reverse, std::string& error);
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 loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error);
bool checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool mergeBranch(RepositoryView &repository, const std::string &source_branch,
const std::string &target_branch, std::string &error);
bool fetch(RepositoryView &repository, const std::string &remote, std::string &error,
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 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,
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 readBranches(RepositoryView& repository, git_branch_t type, std::vector<std::string>& output);
void loadRefBadges(RepositoryView& repository);
void computeGraphLanes(RepositoryView& repository);
bool loadRepositoryData(RepositoryView& repository, std::string& error);
void loadWorkingTree(RepositoryView& repository);
bool runGit(RepositoryView& repository, const std::vector<std::string>& arguments,
const char* success_message, std::string& message, bool reload = true);
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 std::optional<GitAuthOverride> &auth = std::nullopt);
};

View File

@@ -1,155 +1,645 @@
#include "user_data.h"
extern "C" {
#include <ikv.h>
}
#include <izo/Paths.hpp>
#include <ikvxx/ikvxx.hpp>
#include <algorithm>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <optional>
#include <sstream>
#include <utility>
namespace {
std::filesystem::path roaming_directory() {
#ifdef _WIN32
if (const char* appdata = std::getenv("APPDATA")) return appdata;
#include <windows.h>
#include <wincrypt.h>
#endif
if (const char* home = std::getenv("HOME")) return std::filesystem::path(home) / ".config";
return std::filesystem::temp_directory_path();
}
namespace
{
using ArrayIndex = ikv::Value::ArrayIndex;
const ikv_node_t* object_value(const ikv_node_t* object, const char* key, ikv_type_t type) {
const ikv_node_t* value = object ? ikv_object_get(object, key) : nullptr;
return value && ikv_node_type(value) == type ? value : nullptr;
}
}
std::filesystem::path roaming_directory()
{
if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty())
return config;
if (const auto temporary = izo::GetKnownPath(izo::KnownPath::Temporary); !temporary.empty())
return temporary;
return std::filesystem::temp_directory_path();
}
UserData::UserData() {
std::optional<std::pair<std::string, std::string>> splitCredentialPayload(const std::string &payload)
{
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()
{
directory_ = roaming_directory() / "Identity" / "Gitree";
std::filesystem::create_directories(directory_);
imgui_ini_path_ = (directory_ / "imgui.ini").string();
load();
}
UserData::~UserData() {
UserData::~UserData()
{
save();
}
void UserData::load() {
const std::filesystem::path data_path = directory_ / "user_data.ikv";
if (ikv_node_t* root = ikv_parse_file(data_path.string().c_str())) {
if (const ikv_node_t* settings = object_value(root, "settings", IKV_OBJECT)) {
if (const ikv_node_t* value = object_value(settings, "sidebar_width", IKV_FLOAT))
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* 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)));
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.ikv";
}
void UserData::removeLegacyFiles() const
{
std::error_code error;
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();
// ── Primary load: iKvxx binary (.ikv) ────────────────────────────────────
try
{
const ikv::Value root = [&]() -> ikv::Value
{
try
{
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), 12);
for (uint32_t index = 0; index < count; ++index) {
const char* path = ikv_as_string(ikv_array_get(history, index));
if (path && *path) recently_closed_.emplace_back(path);
catch (const ikv::Error &)
{
return ikv::Value::loadBinary(binary_path.string());
}
}();
if (root.isMember("settings") && root["settings"].isObject())
{
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 (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 : "");
if (root.isMember("recently_closed") && root["recently_closed"].isArray())
{
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)
{
const std::string path = arr[static_cast<ArrayIndex>(i)].asString();
if (!path.empty())
{
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);
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);
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;
}
catch (const ikv::Error &)
{
// 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.rfind("sidebar_section_", 0) == 0) {
while (settings >> key)
{
if (key == "sidebar_width") settings >> sidebar_width_;
else if (key == "details_width") settings >> details_width_;
else if (key == "pull_mode") settings >> pull_mode_;
else if (key == "zoom_percent") settings >> zoom_percent_;
else if (key.rfind("sidebar_section_", 0) == 0)
{
const size_t index = static_cast<size_t>(std::stoul(key.substr(16)));
float height = 0.0f;
settings >> height;
if (index < sidebar_section_heights_.size()) sidebar_section_heights_[index] = height;
if (index < sidebar_section_heights_.size())
sidebar_section_heights_[index] = height;
}
}
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f);
std::ifstream history(directory_ / "history.txt");
std::string path;
while (history >> std::quoted(path)) {
if (!path.empty()) recently_closed_.push_back(path);
if (recently_closed_.size() == 12) break;
while (history >> std::quoted(path))
{
if (!path.empty())
{
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_file(directory_ / "session.txt");
session_file >> active_repository_;
while (session_file >> std::quoted(path))
{
if (!path.empty())
open_repositories_.push_back(normalizePath(path));
}
std::ifstream session(directory_ / "session.txt");
session >> active_repository_;
while (session >> std::quoted(path)) open_repositories_.push_back(path);
if (open_repositories_.empty()) active_repository_ = 0;
else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
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(); // migrate to new format immediately
}
void UserData::addRecentlyClosed(const std::string& path) {
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(recently_closed_, path);
recently_closed_.insert(recently_closed_.begin(), path);
if (recently_closed_.size() > 12) recently_closed_.resize(12);
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();
}
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository) {
void UserData::addRecentlyClosed(const std::string &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_, normalized);
recently_closed_.insert(recently_closed_.begin(), normalized);
if (recently_closed_.size() > 100)
recently_closed_.resize(100);
save();
}
std::string UserData::takeRecentlyClosed()
{
if (recently_closed_.empty()) return {};
std::string path = std::move(recently_closed_.front());
recently_closed_.erase(recently_closed_.begin());
save();
return path;
}
void UserData::setRepositorySession(std::vector<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
: std::min(active_repository, open_repositories_.size() - 1);
? 0
: std::min(active_repository, open_repositories_.size() - 1);
save();
}
void UserData::save() const {
std::filesystem::create_directories(directory_);
ikv_node_t* root = ikv_create_object("gitree");
if (!root) return;
ikv_node_t* settings = ikv_object_add_object(root, "settings");
ikv_object_set_float(settings, "sidebar_width", sidebar_width_);
ikv_object_set_float(settings, "details_width", details_width_);
ikv_object_set_int(settings, "pull_mode", pull_mode_);
ikv_node_t* heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT);
for (const float height : sidebar_section_heights_) ikv_array_add_float(heights, height);
ikv_node_t* history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
for (const auto& path : recently_closed_) ikv_array_add_string(history, path.c_str());
ikv_node_t* session = ikv_object_add_object(root, "session");
ikv_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());
ikv_write_file((directory_ / "user_data.ikv").string().c_str(), root);
ikv_free(root);
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::Value root(ikv::objectValue, "gitree");
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_);
auto heights = settings.makeArray("sidebar_sections", ikv::realValue);
for (const float h : sidebar_section_heights_)
heights.append(static_cast<double>(h));
auto open = settings.makeArray("sidebar_section_open", ikv::booleanValue);
for (const bool b : sidebar_section_open_)
open.append(b);
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));
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,42 +2,102 @@
#include <array>
#include <filesystem>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
class UserData {
struct SavedGitCredential
{
std::string remote_url;
std::string username;
std::string password;
};
class UserData
{
public:
UserData();
~UserData();
[[nodiscard]] const std::filesystem::path& directory() const { return directory_; }
[[nodiscard]] const std::string& imguiIniPath() const { return imgui_ini_path_; }
[[nodiscard]] const std::vector<std::string>& recentlyClosed() const { return recently_closed_; }
[[nodiscard]] const std::vector<std::string>& openRepositories() const { return open_repositories_; }
[[nodiscard]] const std::filesystem::path &directory() const { return directory_; }
[[nodiscard]] const std::vector<std::string> &recentRepositories() const { return recent_repositories_; }
[[nodiscard]] const std::vector<std::string> &recentlyClosed() const { return recently_closed_; }
[[nodiscard]] const std::vector<std::string> &openRepositories() const { return open_repositories_; }
[[nodiscard]] size_t activeRepository() const { return active_repository_; }
[[nodiscard]] float sidebarWidth() const { return sidebar_width_; }
[[nodiscard]] float detailsWidth() const { return details_width_; }
[[nodiscard]] float sidebarSectionHeight(size_t index) const { return sidebar_section_heights_.at(index); }
[[nodiscard]] 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 setPullMode(int mode) { pull_mode_ = mode; save(); }
void addRecentlyClosed(const std::string& path);
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;
save();
}
void setZoomPercent(int percent)
{
zoom_percent_ = percent;
save();
}
void addRecentRepository(const std::string &path);
void addRecentlyClosed(const std::string &path);
std::string takeRecentlyClosed();
void setRepositorySession(std::vector<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::string imgui_ini_path_;
std::vector<std::string> recent_repositories_;
std::vector<std::string> recently_closed_;
std::vector<std::string> open_repositories_;
size_t active_repository_ = 0;
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

@@ -2,36 +2,155 @@
#include <GLFW/glfw3.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <vector>
#ifdef _WIN32
#define GLFW_EXPOSE_NATIVE_WIN32
#include <GLFW/glfw3native.h>
#include <dwmapi.h>
#include <windows.h>
#include <wincodec.h>
#endif
WindowManager::~WindowManager() {
#ifdef _WIN32
namespace
{
HICON loadPngIcon(const std::filesystem::path &path, UINT size, bool rounded)
{
const HRESULT com_result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
const bool uninitialize_com = SUCCEEDED(com_result);
IWICImagingFactory *factory = nullptr;
IWICBitmapDecoder *decoder = nullptr;
IWICBitmapFrameDecode *frame = nullptr;
IWICBitmapScaler *scaler = nullptr;
IWICFormatConverter *converter = nullptr;
HICON icon = nullptr;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&factory))) ||
FAILED(factory->CreateDecoderFromFilename(path.c_str(), nullptr, GENERIC_READ,
WICDecodeMetadataCacheOnLoad, &decoder)) ||
FAILED(decoder->GetFrame(0, &frame)) ||
FAILED(factory->CreateBitmapScaler(&scaler)) ||
FAILED(scaler->Initialize(frame, size, size, WICBitmapInterpolationModeFant)) ||
FAILED(factory->CreateFormatConverter(&converter)) ||
FAILED(converter->Initialize(scaler, GUID_WICPixelFormat32bppBGRA,
WICBitmapDitherTypeNone, nullptr, 0.0,
WICBitmapPaletteTypeCustom)))
{
goto cleanup;
}
{
std::vector<BYTE> pixels(static_cast<size_t>(size) * size * 4);
if (FAILED(converter->CopyPixels(nullptr, size * 4,
static_cast<UINT>(pixels.size()), pixels.data())))
{
goto cleanup;
}
if (rounded)
{
const float radius = static_cast<float>(size) * 0.22f;
const float maximum = static_cast<float>(size) - radius - 0.5f;
const float minimum = radius - 0.5f;
for (UINT y = 0; y < size; ++y)
{
for (UINT x = 0; x < size; ++x)
{
const float nearest_x = std::clamp(static_cast<float>(x), minimum, maximum);
const float nearest_y = std::clamp(static_cast<float>(y), minimum, maximum);
const float dx = static_cast<float>(x) - nearest_x;
const float dy = static_cast<float>(y) - nearest_y;
const float coverage = std::clamp(radius + 0.5f - std::sqrt(dx * dx + dy * dy),
0.0f, 1.0f);
pixels[(static_cast<size_t>(y) * size + x) * 4 + 3] =
static_cast<BYTE>(coverage * 255.0f);
}
}
}
BITMAPV5HEADER header{};
header.bV5Size = sizeof(header);
header.bV5Width = static_cast<LONG>(size);
header.bV5Height = -static_cast<LONG>(size);
header.bV5Planes = 1;
header.bV5BitCount = 32;
header.bV5Compression = BI_BITFIELDS;
header.bV5RedMask = 0x00ff0000;
header.bV5GreenMask = 0x0000ff00;
header.bV5BlueMask = 0x000000ff;
header.bV5AlphaMask = 0xff000000;
void *bitmap_bits = nullptr;
const HDC screen = GetDC(nullptr);
const HBITMAP color = CreateDIBSection(screen, reinterpret_cast<BITMAPINFO *>(&header),
DIB_RGB_COLORS, &bitmap_bits, nullptr, 0);
ReleaseDC(nullptr, screen);
const HBITMAP mask = CreateBitmap(size, size, 1, 1, nullptr);
if (color && mask && bitmap_bits)
{
std::memcpy(bitmap_bits, pixels.data(), pixels.size());
ICONINFO info{};
info.fIcon = TRUE;
info.hbmColor = color;
info.hbmMask = mask;
icon = CreateIconIndirect(&info);
}
if (color)
DeleteObject(color);
if (mask)
DeleteObject(mask);
}
cleanup:
if (converter)
converter->Release();
if (scaler)
scaler->Release();
if (frame)
frame->Release();
if (decoder)
decoder->Release();
if (factory)
factory->Release();
if (uninitialize_com)
CoUninitialize();
return icon;
}
} // namespace
#endif
WindowManager::~WindowManager()
{
destroy();
}
bool WindowManager::create(const char* title, int width, int height) {
bool WindowManager::create(const char *title, int width, int height)
{
#ifdef _WIN32
using SetDpiAwarenessContext = BOOL(WINAPI*)(HANDLE);
using SetDpiAwarenessContext = BOOL(WINAPI *)(HANDLE);
const HMODULE user32 = GetModuleHandleW(L"user32.dll");
const FARPROC dpi_address = GetProcAddress(user32, "SetProcessDpiAwarenessContext");
SetDpiAwarenessContext set_dpi_awareness = nullptr;
static_assert(sizeof(set_dpi_awareness) == sizeof(dpi_address));
std::memcpy(&set_dpi_awareness, &dpi_address, sizeof(set_dpi_awareness));
if (set_dpi_awareness) set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
if (set_dpi_awareness)
set_dpi_awareness(reinterpret_cast<HANDLE>(-4)); // Per-monitor v2
#endif
if (!glfwInit()) return false;
if (!glfwInit())
return false;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_SCALE_TO_MONITOR, GLFW_TRUE);
window_ = glfwCreateWindow(width, height, title, nullptr, nullptr);
if (!window_) {
if (!window_)
{
glfwTerminate();
return false;
}
@@ -45,13 +164,17 @@ bool WindowManager::create(const char* title, int width, int height) {
glfwGetWindowContentScale(window_, &x_scale, &y_scale);
dpi_scale_ = std::max(x_scale, y_scale);
applyNativeCaption();
applyNativeIcons();
return true;
}
void WindowManager::destroy() {
if (!window_) return;
void WindowManager::destroy()
{
if (!window_)
return;
glfwDestroyWindow(window_);
window_ = nullptr;
destroyNativeIcons();
glfwTerminate();
}
@@ -60,39 +183,126 @@ void WindowManager::swapBuffers() { glfwSwapBuffers(window_); }
void WindowManager::requestClose() { glfwSetWindowShouldClose(window_, GLFW_TRUE); }
bool WindowManager::shouldClose() const { return !window_ || glfwWindowShouldClose(window_); }
bool WindowManager::consumeDpiChange() {
bool WindowManager::consumeDpiChange()
{
const bool changed = dpi_changed_;
dpi_changed_ = false;
return changed;
}
void WindowManager::contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale) {
auto* manager = static_cast<WindowManager*>(glfwGetWindowUserPointer(window));
if (manager) manager->updateDpi(std::max(x_scale, y_scale));
void WindowManager::contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale)
{
auto *manager = static_cast<WindowManager *>(glfwGetWindowUserPointer(window));
if (manager)
manager->updateDpi(std::max(x_scale, y_scale));
}
void WindowManager::updateDpi(float scale) {
void WindowManager::updateDpi(float scale)
{
scale = std::clamp(scale, 1.0f, 4.0f);
if (scale == dpi_scale_) return;
if (scale == dpi_scale_)
return;
dpi_scale_ = scale;
dpi_changed_ = true;
applyNativeCaption();
}
void WindowManager::applyNativeCaption() const {
void WindowManager::applyNativeCaption() const
{
#ifdef _WIN32
if (!window_)
return;
const HWND hwnd = glfwGetWin32Window(window_);
if (!hwnd)
return;
const BOOL dark = TRUE;
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)); // DWMWA_USE_IMMERSIVE_DARK_MODE
const DWORD square_corners = 1; // DWMWCP_DONOTROUND
DwmSetWindowAttribute(hwnd, 33, &square_corners, sizeof(square_corners));
// DWMWA_USE_IMMERSIVE_DARK_MODE
DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark));
DWORD corner_pref = 0;
switch (corner_mode_)
{
case WindowCornerMode::Default:
corner_pref = 0; // DWMWCP_DEFAULT
break;
case WindowCornerMode::DoNotRound:
corner_pref = 1; // DWMWCP_DONOTROUND
break;
case WindowCornerMode::Round:
corner_pref = 2; // DWMWCP_ROUND
break;
case WindowCornerMode::RoundSmall:
corner_pref = 3; // DWMWCP_ROUNDSMALL
break;
}
// DWMWA_WINDOW_CORNER_PREFERENCE
DwmSetWindowAttribute(hwnd, 33, &corner_pref, sizeof(corner_pref));
// Windows 11 caption customization. Older versions safely ignore these.
const COLORREF caption = RGB(32, 32, 32);
const COLORREF caption = static_cast<COLORREF>(caption_color_);
const COLORREF border = RGB(51, 55, 63);
const COLORREF text = RGB(199, 203, 209);
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption)); // DWMWA_CAPTION_COLOR
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border)); // DWMWA_BORDER_COLOR
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text)); // DWMWA_TEXT_COLOR
// DWMWA_BORDER_COLOR
DwmSetWindowAttribute(hwnd, 34, &border, sizeof(border));
// DWMWA_CAPTION_COLOR
DwmSetWindowAttribute(hwnd, 35, &caption, sizeof(caption));
// DWMWA_TEXT_COLOR
DwmSetWindowAttribute(hwnd, 36, &text, sizeof(text));
#endif
}
void WindowManager::applyNativeIcons()
{
#ifdef _WIN32
destroyNativeIcons();
const HWND hwnd = glfwGetWin32Window(window_);
const auto asset_dir = std::filesystem::path(GITREE_IMAGE_ASSET_DIR);
window_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 32, true);
taskbar_icon_ = loadPngIcon(asset_dir / L"gitree_logo.png", 64, true);
if (window_icon_)
{
SendMessageW(hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(window_icon_));
SendMessageW(hwnd, WM_SETICON, ICON_SMALL2, reinterpret_cast<LPARAM>(window_icon_));
}
if (taskbar_icon_)
{
SendMessageW(hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(taskbar_icon_));
}
#endif
}
void WindowManager::destroyNativeIcons()
{
#ifdef _WIN32
if (window_icon_)
DestroyIcon(static_cast<HICON>(window_icon_));
if (taskbar_icon_)
DestroyIcon(static_cast<HICON>(taskbar_icon_));
#endif
window_icon_ = nullptr;
taskbar_icon_ = nullptr;
}
void WindowManager::setCornerMode(WindowCornerMode mode)
{
corner_mode_ = mode;
applyNativeCaption();
}
void WindowManager::setCaptionColor(std::uint32_t color)
{
caption_color_ = color;
applyNativeCaption();
}

View File

@@ -1,31 +1,62 @@
#pragma once
#include <cstdint>
struct GLFWwindow;
class WindowManager {
class WindowManager
{
public:
enum class WindowCornerMode
{
Default,
DoNotRound,
Round,
RoundSmall
};
public:
WindowManager() = default;
~WindowManager();
WindowManager(const WindowManager&) = delete;
WindowManager& operator=(const WindowManager&) = delete;
WindowManager(const WindowManager &) = delete;
WindowManager &operator=(const WindowManager &) = delete;
bool create(const char* title, int width, int height);
bool create(const char *title, int width, int height);
void destroy();
void pollEvents();
void swapBuffers();
void requestClose();
[[nodiscard]] bool shouldClose() const;
[[nodiscard]] GLFWwindow* nativeWindow() const { return window_; }
[[nodiscard]] GLFWwindow *nativeWindow() const { return window_; }
[[nodiscard]] float dpiScale() const { return dpi_scale_; }
bool consumeDpiChange();
void setCornerMode(WindowCornerMode mode);
[[nodiscard]] WindowCornerMode cornerMode() const { return corner_mode_; }
void setCaptionColor(std::uint32_t color);
[[nodiscard]] std::uint32_t captionColor() const { return caption_color_; }
private:
static void contentScaleCallback(GLFWwindow* window, float x_scale, float y_scale);
static void contentScaleCallback(GLFWwindow *window, float x_scale, float y_scale);
void updateDpi(float scale);
void applyNativeCaption() const;
void applyNativeIcons();
void destroyNativeIcons();
GLFWwindow *window_ = nullptr;
void *window_icon_ = nullptr;
void *taskbar_icon_ = nullptr;
GLFWwindow* window_ = nullptr;
float dpi_scale_ = 1.0f;
bool dpi_changed_ = false;
WindowCornerMode corner_mode_ = WindowCornerMode::Round;
// 0x00BBGGRR, same layout Windows COLORREF uses.
std::uint32_t caption_color_ = 0x00201B19;
};

View File

@@ -1,6 +1,8 @@
#pragma once
#include <git2.h>
#include <chrono>
#include <map>
#include <set>
#include <string>
#include <vector>
@@ -28,37 +30,66 @@ struct WorkingFile {
bool staged = false;
};
struct BranchDivergence {
size_t ahead = 0;
size_t behind = 0;
};
enum class ToolbarHistoryActionKind { none, checkout, commit };
struct ToolbarHistoryAction {
ToolbarHistoryActionKind kind = ToolbarHistoryActionKind::none;
bool available = false;
std::string tooltip;
std::string source;
std::string target;
std::string summary;
};
struct CommitInfo {
git_oid oid{};
std::string short_id;
std::string summary;
std::string description;
std::string author;
std::string email;
std::string date;
int parents = 0;
int lane = 0;
int graph_color = 0;
std::vector<git_oid> parent_ids;
std::vector<RefBadge> refs;
std::vector<ChangedFile> changed_files;
std::vector<ChangedFile> all_files;
bool changes_loaded = false;
bool all_files_loaded = false;
};
struct RepositoryView {
git_repository* repo = nullptr;
git_revwalk* commit_walk = nullptr;
bool history_exhausted = false;
bool credentials_checked = false;
std::string path;
std::string name = "New Tab";
std::string branch = "detached";
std::vector<std::string> local_branches;
std::vector<std::string> remote_branches;
std::map<std::string, BranchDivergence> local_branch_divergence;
std::map<std::string, BranchDivergence> remote_branch_divergence;
std::vector<std::string> remotes;
std::vector<std::string> worktrees;
std::set<std::string> worktree_branches;
std::vector<std::string> submodules;
std::map<std::string, unsigned int> submodule_statuses;
std::vector<CommitInfo> commits;
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;
RepositoryView() = default;
~RepositoryView() { close(); }
@@ -70,5 +101,6 @@ struct RepositoryView {
commit_walk = nullptr;
if (repo) git_repository_free(repo);
repo = nullptr;
credentials_checked = false;
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,20 @@
#include <vector>
class GitManager;
class AvatarCache;
struct RepositoryView;
struct ImFont;
class DiffViewer {
public:
void open(RepositoryView& repository, GitManager& manager, const std::string& path,
bool staged, std::string& notice);
void openCommit(RepositoryView& repository, GitManager& manager, const std::string& path,
const std::string& commit_id, std::string& notice);
void close();
bool isOpen() const { return !path_.empty(); }
void draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice);
void draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
float scale, ImFont* code_font, std::string& notice);
private:
enum class Mode { diff, file, blame, history };
@@ -29,17 +34,40 @@ private:
std::vector<Line> lines;
std::string patch;
};
struct BlameLine {
std::string hash;
std::string author;
std::string email;
std::string date;
std::string summary;
std::string text;
int line_number = 0;
bool show_attribution = false;
};
struct HistoryEntry {
std::string hash;
std::string date;
std::string author;
std::string summary;
};
std::string path_;
std::string commit_id_;
bool staged_ = false;
Mode mode_ = Mode::diff;
std::string file_header_;
std::vector<Hunk> hunks_;
std::vector<std::string> file_lines_;
std::vector<std::string> blame_lines_;
std::vector<std::string> history_lines_;
std::vector<BlameLine> blame_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

@@ -2,10 +2,112 @@
#include "managers/avatar_cache.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <IconsTabler.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
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) {
static_assert(Count >= 2);
draw->PathClear();
draw->PathLineTo(points.front());
for (size_t index = 1; index + 1 < Count; ++index) {
const ImVec2 previous = points[index - 1];
const ImVec2 corner = points[index];
const ImVec2 next = points[index + 1];
const float incoming_length = std::hypot(corner.x - previous.x, corner.y - previous.y);
const float outgoing_length = std::hypot(next.x - corner.x, next.y - corner.y);
if (incoming_length < 0.01f || outgoing_length < 0.01f) continue;
const float turn_radius = std::min({radius, incoming_length * 0.5f, outgoing_length * 0.5f});
const ImVec2 incoming{
(corner.x - previous.x) / incoming_length,
(corner.y - previous.y) / incoming_length,
};
const ImVec2 outgoing{
(next.x - corner.x) / outgoing_length,
(next.y - corner.y) / outgoing_length,
};
const ImVec2 before{
corner.x - incoming.x * turn_radius,
corner.y - incoming.y * turn_radius,
};
const ImVec2 after{
corner.x + outgoing.x * turn_radius,
corner.y + outgoing.y * turn_radius,
};
constexpr float control = 0.55228475f;
draw->PathLineTo(before);
draw->PathBezierCubicCurveTo(
{before.x + incoming.x * turn_radius * control,
before.y + incoming.y * turn_radius * control},
{after.x - outgoing.x * turn_radius * control,
after.y - outgoing.y * turn_radius * control},
after);
}
draw->PathLineTo(points.back());
draw->PathStroke(color, ImDrawFlags_None, thickness);
}
void drawOrthogonalEdge(ImDrawList* draw, const ImVec2& child, const ImVec2& parent,
ImU32 color, float scale, int route_slot, float detour_x) {
const float thickness = 2.0f * scale;
const float horizontal = parent.x - child.x;
const float vertical = parent.y - child.y;
if (vertical <= 0.0f) {
draw->AddLine(child, parent, color, thickness);
return;
}
// 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)) {
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(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{final_detour_x, child.y}, ImVec2{final_detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
}
return;
}
if (std::abs(horizontal) < 0.5f * scale) {
draw->AddLine(child, parent, color, thickness);
return;
}
// Bends remain anchored to commit rows, but use a compact quarter-round turn.
if (route_slot == 0)
drawRoundedPolyline(draw, std::array{child, ImVec2{child.x, parent.y}, parent},
7.0f * scale, color, thickness);
else
drawRoundedPolyline(draw, std::array{child, ImVec2{parent.x, child.y}, parent},
7.0f * scale, color, thickness);
}
} // namespace
ImU32 GraphRenderer::laneColor(int lane, int alpha) {
static constexpr ImVec4 colors[] = {
@@ -25,35 +127,47 @@ ImU32 GraphRenderer::laneColor(int lane, int alpha) {
float GraphRenderer::requiredWidth(const std::vector<CommitInfo>& commits, float scale) {
int maximum_lane = 0;
for (const auto& commit : commits) maximum_lane = std::max(maximum_lane, commit.lane);
return std::clamp((42.0f + maximum_lane * 18.0f) * scale, 56.0f * scale, 220.0f * scale);
// Keep one spare lane available for edges that must route around occupied commit lanes.
return std::max((68.0f + maximum_lane * 28.0f) * scale, 82.0f * scale);
}
void GraphRenderer::drawRow(int row, const CommitInfo& commit,
const std::vector<CommitInfo>& commits, const std::vector<float>& row_heights,
const std::vector<std::vector<int>>& parent_rows, AvatarCache* avatars) const {
const std::vector<std::vector<int>>& parent_rows, AvatarCache* avatars,
const ImVec2* ref_connector_start) const {
ImDrawList* draw = ImGui::GetWindowDrawList();
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float row_height = row_heights[static_cast<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(18.0f);
const float x = origin.x + px(15.0f) + lane_spacing * commit.lane;
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;
const float row_clip_padding = ImGui::GetStyle().CellPadding.y + px(1.0f);
// GitKraken-style lane ribbon: a quiet tint carries the branch color through
// the rest of the graph column while the far edge remains crisply identifiable.
// Start the lane ribbon at the profile circle's centerline. The opaque node backing
// masks its left edge, while the vertical inset keeps neighboring row tints separate.
const float ribbon_left = std::min(x, cell_right);
const float ribbon_top = origin.y + px(1.0f);
const float ribbon_bottom = origin.y + content_height - px(1.0f);
draw->AddRectFilled(
{x, origin.y - row_clip_padding},
{cell_right, origin.y + content_height + row_clip_padding},
laneColor(commit.lane, 38));
{ribbon_left, ribbon_top},
{cell_right, ribbon_bottom},
laneColor(commit.graph_color, 38));
draw->AddLine(
{cell_right - px(1.0f), origin.y - row_clip_padding},
{cell_right - px(1.0f), origin.y + content_height + row_clip_padding},
laneColor(commit.lane, 220), px(2.0f));
if (!commit.refs.empty())
draw->AddLine({origin.x - ImGui::GetStyle().CellPadding.x, y}, {x, y},
laneColor(commit.lane, 150), px(1.0f));
{cell_right - px(1.0f), ribbon_top},
{cell_right - px(1.0f), ribbon_bottom},
laneColor(commit.graph_color, 215), px(2.0f));
if (ref_connector_start) {
const ImVec2 end{x, y};
const float turn_x = end.x - px(8.0f);
draw->PushClipRectFullScreen();
drawRoundedPolyline(draw,
std::array{*ref_connector_start, ImVec2{turn_x, ref_connector_start->y},
ImVec2{turn_x, end.y}, end},
px(5.0f), laneColor(commit.graph_color, 190), px(1.8f));
draw->PopClipRect();
}
std::vector<float> row_offsets(row_heights.size() + 1, 0.0f);
for (size_t index = 0; index < row_heights.size(); ++index)
@@ -64,57 +178,124 @@ 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)];
for (const int parent_row : parent_rows[static_cast<size_t>(child_row)]) {
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(15.0f) + lane_spacing * child.lane;
const float parent_x = origin.x + px(15.0f) + lane_spacing * parent.lane;
const float child_y = center_y(child_row);
const float parent_y = center_y(parent_row);
// An edge belongs to the branch it leaves, including the curved transition
// into a parent lane, so its color stays consistent with the child node/ref chip.
const ImU32 color = laneColor(child.lane, 225);
if (child.lane == parent.lane) {
draw->AddLine({child_x, child_y}, {parent_x, parent_y}, color, px(1.8f));
continue;
}
const float 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 bend_height = std::min(parent_y - child_y, px(24.0f));
const float curve_end_y = child_y + bend_height;
draw->AddBezierCubic(
{child_x, child_y},
{child_x, child_y + bend_height * 0.45f},
{parent_x, child_y + bend_height * 0.55f},
{parent_x, curve_end_y},
color, px(1.8f));
if (curve_end_y < parent_y)
draw->AddLine({parent_x, curve_end_y}, {parent_x, parent_y}, color, px(1.8f));
}
drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y},
laneColor(route.color_lane, 235), scale_, route.route_slot, detour_x);
}
draw->PopClipRect();
const ImU32 lane_color = laneColor(commit.lane);
const ImU32 lane_color = laneColor(commit.graph_color);
const auto show_identity_tooltip = [&](float hit_radius) {
if (!ImGui::IsWindowHovered()) return;
const ImVec2 mouse = ImGui::GetIO().MousePos;
const float delta_x = mouse.x - x;
const float delta_y = mouse.y - y;
if (delta_x * delta_x + delta_y * delta_y > hit_radius * hit_radius) return;
if (commit.email.empty()) ImGui::SetTooltip("%s", commit.author.c_str());
else ImGui::SetTooltip("%s <%s>", commit.author.c_str(), commit.email.c_str());
};
if (commit.parents > 1) {
draw->AddCircleFilled({x, y}, px(4.5f), lane_color);
draw->AddCircle({x, y}, px(6.0f), laneColor(commit.lane, 110), 0, px(1.0f));
draw->AddCircleFilled({x, y}, px(6.0f), lane_color);
draw->AddCircle({x, y}, px(7.5f), laneColor(commit.graph_color, 130), 0, px(1.2f));
show_identity_tooltip(px(9.0f));
ImGui::Dummy({0.0f, content_height});
return;
}
const float radius = px(8.4f);
const float radius = px(9.6f);
// The opaque backing masks every lane segment before the avatar is painted.
draw->AddCircleFilled({x, y}, radius + px(2.2f), IM_COL32(19, 24, 31, 255));
draw->AddCircle({x, y}, radius + px(1.0f), lane_color, 0, px(2.0f));
draw->AddCircleFilled({x, y}, radius + px(1.8f), IM_COL32(19, 24, 31, 255));
draw->AddCircle({x, y}, radius + px(0.8f), lane_color, 0, px(2.0f));
draw->AddCircleFilled({x, y}, radius - px(1.0f), IM_COL32(232, 238, 242, 255));
const unsigned int texture = avatars ? avatars->textureFor(commit.email) : 0;
@@ -124,8 +305,9 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
{x + radius - px(1.5f), y + radius - px(1.5f)},
{0, 0}, {1, 1}, IM_COL32_WHITE, radius);
} else {
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_FA_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_FA_USER);
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_TB_USER);
}
show_identity_tooltip(radius + px(2.0f));
ImGui::Dummy({0.0f, content_height});
}

View File

@@ -15,7 +15,7 @@ public:
void drawRow(int row, const CommitInfo& commit, const std::vector<CommitInfo>& commits,
const std::vector<float>& row_heights, const std::vector<std::vector<int>>& parent_rows,
AvatarCache* avatars) const;
AvatarCache* avatars, const ImVec2* ref_connector_start = nullptr) const;
private:
float px(float value) const { return value * scale_; }

BIN
vendor/fonts/Inter-Regular.ttf vendored Normal file

Binary file not shown.

BIN
vendor/fonts/Inter-SemiBold.ttf vendored Normal file

Binary file not shown.

Binary file not shown.

2
vendor/glfw vendored

1
vendor/iKv vendored

Submodule vendor/iKv deleted from d0b02f4735

1
vendor/iKvxx vendored Submodule

Submodule vendor/iKvxx added at 99d3ea7b83

2
vendor/iZo vendored

Submodule vendor/iZo updated: 80c6bfce90...355a757a17

76
vendor/icons/IconsTabler.h vendored Normal file
View File

@@ -0,0 +1,76 @@
#pragma once
// Tabler Icons 3.44.0 outline webfont glyphs used by Gitree.
#define ICON_MIN_TB 0xEA06
#define ICON_MAX_TB 0xFAF7
#define ICON_TB_ANGLE_RIGHT "\xee\xa9\xa1"
#define ICON_TB_ARROW_DOWN "\xee\xa8\x96"
#define ICON_TB_ARROW_DOWN_A_Z "\xee\xbc\x98"
#define ICON_TB_ARROW_DOWN_Z_A "\xee\xbc\x9a"
#define ICON_TB_ARROW_RIGHT_ARROW_LEFT "\xef\x87\xb4"
#define ICON_TB_ARROW_UP "\xee\xa8\xa5"
#define ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE "\xee\xaa\x99"
#define ICON_TB_BARS "\xee\xb1\x82"
#define ICON_TB_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"
#define ICON_TB_CHECK "\xee\xa9\x9e"
#define ICON_TB_CHEVRON_DOWN "\xee\xa9\x9f"
#define ICON_TB_CHEVRON_RIGHT "\xee\xa9\xa1"
#define ICON_TB_CIRCLE_CHEVRON_LEFT "\xef\x98\xa3"
#define ICON_TB_CIRCLE_CHEVRON_RIGHT "\xef\x98\xa4"
#define ICON_TB_CIRCLE_DOT "\xee\xbe\xb1"
#define ICON_TB_CIRCLE_NODES "\xee\xaa\xb5"
#define ICON_TB_CLOCK_ROTATE_LEFT "\xee\xaf\xaa"
#define ICON_TB_CLOUD "\xee\xa9\xb6"
#define ICON_TB_CODE "\xee\xa9\xb7"
#define ICON_TB_CODE_BRANCH "\xee\xaa\xb2"
#define ICON_TB_COMPUTER "\xee\xaa\x89"
#define ICON_TB_COPY "\xee\xa9\xba"
#define ICON_TB_CUBES "\xee\xb8\x97"
#define ICON_TB_DESKTOP "\xee\xaa\x89"
#define ICON_TB_DEVICE_LAPTOP "\xee\xad\xa4"
#define ICON_TB_DOWNLOAD "\xee\xaa\x96"
#define ICON_TB_EYE "\xee\xaa\x9a"
#define ICON_TB_FILE "\xee\xaa\xa4"
#define ICON_TB_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"
#define ICON_TB_ROBOT "\xef\x80\x8b"
#define ICON_TB_ROTATE_LEFT "\xee\xac\x96"
#define ICON_TB_ROTATE_RIGHT "\xee\xac\x95"
#define ICON_TB_SERVER "\xee\xac\x9f"
#define ICON_TB_TAG "\xee\xbe\x86"
#define ICON_TB_TERMINAL "\xee\xaf\xaf"
#define ICON_TB_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"
#define ICON_TB_UPLOAD "\xee\xad\x87"
#define ICON_TB_USER "\xee\xad\x8d"
#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa"
#define ICON_TB_XMARK "\xee\xad\x95"
#define ICON_TB_FILE_TYPE_SQL "\xef\xac\x95"

2
vendor/imgui vendored

2
vendor/libgit2 vendored