45 Commits

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

View File

@@ -1,12 +1,16 @@
name: Build Windows EXE
name: Build Releases
on:
push:
branches:
- "**"
env:
APP_NAME: gitree
jobs:
build-windows:
name: Build Windows x64
runs-on: ubuntu-latest
steps:
@@ -14,6 +18,17 @@ jobs:
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Generate build names
env:
GITEA_SHA: ${{ gitea.sha }}
run: |
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV"
echo "WINDOWS_EXE=${APP_NAME}-windows-x64-${short_sha}.exe" >> "$GITHUB_ENV"
echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64-${short_sha}" >> "$GITHUB_ENV"
- name: Install dependencies
run: |
@@ -25,10 +40,13 @@ jobs:
cat > mingw-toolchain.cmake <<'EOF'
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
@@ -38,46 +56,324 @@ 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: Generate build names
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
run: |
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV"
echo "LINUX_DEB=${APP_NAME}-deb-x64-${short_sha}.deb" >> "$GITHUB_ENV"
echo "LINUX_ARTIFACT=${APP_NAME}-deb-x64-${short_sha}" >> "$GITHUB_ENV"
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build curl jq dpkg-dev
- name: Configure Linux release build
run: |
cmake -S . -B build-linux -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF
- name: Build Linux executable
run: cmake --build build-linux --parallel
- name: Locate Linux executable
run: |
exe_path=""
if [ -f "build-linux/bin/${APP_NAME}" ]; then
exe_path="build-linux/bin/${APP_NAME}"
elif [ -f "build-linux/bin/Gitree" ]; then
exe_path="build-linux/bin/Gitree"
elif [ -f "build-linux/bin/gitree" ]; then
exe_path="build-linux/bin/gitree"
else
exe_path="$(find build-linux -type f -executable -name "${APP_NAME}" | head -n 1)"
fi
if [ -z "$exe_path" ]; then
echo "Could not find built Linux executable."
exit 1
fi
echo "LINUX_EXE_PATH=${exe_path}" >> "$GITHUB_ENV"
echo "Found Linux executable: ${exe_path}"
- name: Package Linux DEB
run: |
mkdir -p "pkg/DEBIAN"
mkdir -p "pkg/usr/bin"
cp "$LINUX_EXE_PATH" "pkg/usr/bin/${APP_NAME}"
chmod 755 "pkg/usr/bin/${APP_NAME}"
version="0.0.0+${BUILD_HASH}"
cat > "pkg/DEBIAN/control" <<EOF
Package: ${APP_NAME}
Version: ${version}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: GigabiteStudios <ceo@gigabitestudios.xyz>
Description: ${APP_NAME}
${APP_NAME} Linux x64 build.
EOF
mkdir -p dist
dpkg-deb --build pkg "dist/${LINUX_DEB}"
- name: Upload Linux DEB build
uses: actions/upload-artifact@v3
with:
name: ${{ env.LINUX_ARTIFACT }}
path: dist/${{ env.LINUX_DEB }}
if-no-files-found: error
release:
name: Create Release
runs-on: ubuntu-latest
needs:
- build-windows
- build-linux
if: ${{ always() && gitea.ref_name == 'prod' }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SHA: ${{ gitea.sha }}
WINDOWS_RESULT: ${{ needs.build-windows.result }}
LINUX_RESULT: ${{ needs.build-linux.result }}
APP_NAME: gitree
steps:
- name: Check build results
run: |
echo "Windows result: ${WINDOWS_RESULT}"
echo "Linux result: ${LINUX_RESULT}"
if [ "$WINDOWS_RESULT" != "success" ] && [ "$LINUX_RESULT" != "success" ]; then
echo "Both Windows and Linux builds failed. Refusing to create release."
exit 1
fi
- name: Check out repository
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Generate release names
run: |
short_sha="$(printf '%s' "$GITEA_SHA" | cut -c1-7)"
echo "BUILD_HASH=${short_sha}" >> "$GITHUB_ENV"
echo "RELEASE_TAG=release-${short_sha}" >> "$GITHUB_ENV"
echo "RELEASE_NAME=Release ${short_sha}" >> "$GITHUB_ENV"
echo "WINDOWS_EXE=${APP_NAME}-windows-x64-${short_sha}.exe" >> "$GITHUB_ENV"
echo "WINDOWS_ARTIFACT=${APP_NAME}-windows-x64-${short_sha}" >> "$GITHUB_ENV"
echo "LINUX_DEB=${APP_NAME}-deb-x64-${short_sha}.deb" >> "$GITHUB_ENV"
echo "LINUX_ARTIFACT=${APP_NAME}-deb-x64-${short_sha}" >> "$GITHUB_ENV"
- name: Install release dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl jq
- name: Download Windows artifact
if: ${{ needs.build-windows.result == 'success' }}
uses: actions/download-artifact@v3
with:
name: ${{ env.WINDOWS_ARTIFACT }}
path: release-assets
- name: Download Linux artifact
if: ${{ needs.build-linux.result == 'success' }}
uses: actions/download-artifact@v3
with:
name: ${{ env.LINUX_ARTIFACT }}
path: release-assets
- name: Create prod release
run: |
if [ -z "$GITEA_TOKEN" ]; then
echo "The repository secret GITEA_TOKEN is required to publish prod releases."
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 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 +381,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

View File

@@ -70,6 +70,10 @@ 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)
@@ -77,6 +81,7 @@ target_link_libraries(gitree PRIVATE imgui libgit2package iZo::izo ikv::ikv Open
target_compile_definitions(gitree PRIVATE
GITREE_VERSION="${PROJECT_VERSION}"
GITREE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/vendor/fonts"
GITREE_IMAGE_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
$<$<PLATFORM_ID:Windows>:NOMINMAX;WIN32_LEAN_AND_MEAN>
)

103
README.md
View File

@@ -1,42 +1,93 @@
# Gitree
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)
[![Platform](https://img.shields.io/badge/platform-Windows_x64-0078D4?logo=windows)](https://dock-it.dev/Idea-Studios/Gitree/releases)
[![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 executable 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.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)
- [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
- `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.
Gitree is licensed under the [Creative Commons Attribution-ShareAlike 4.0
International license](https://dock-it.dev/Idea-Studios/Gitree/src/branch/prod/LICENSE).

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

@@ -4,56 +4,66 @@
#include <string>
#include <vector>
class GitManager {
class GitManager
{
public:
GitManager();
~GitManager();
bool openRepository(RepositoryView& repository, const std::string& path, std::string& error);
bool initializeRepository(RepositoryView& repository, const std::string& path, std::string& error);
bool reload(RepositoryView& repository, std::string& error);
bool loadMoreCommits(RepositoryView& repository, size_t page_size, std::string& error);
bool checkoutBranch(RepositoryView& repository, const std::string& branch, std::string& error);
bool fetch(RepositoryView& repository, const std::string& remote, std::string& error);
bool pull(RepositoryView& repository, int mode, std::string& error);
bool push(RepositoryView& repository, std::string& error);
bool stash(RepositoryView& repository, std::string& error);
bool popStash(RepositoryView& repository, std::string& error);
bool undoCommit(RepositoryView& repository, std::string& error);
bool redoCommit(RepositoryView& repository, std::string& error);
bool stageAll(RepositoryView& repository, std::string& error);
bool stageFile(RepositoryView& repository, const std::string& path, std::string& error);
bool unstageFile(RepositoryView& repository, const std::string& path, std::string& error);
bool discardFile(RepositoryView& repository, const std::string& path, std::string& error);
bool discardAll(RepositoryView& repository, std::string& error);
bool commit(RepositoryView& repository, const std::string& summary,
const std::string& description, bool amend, std::string& error);
bool createBranch(RepositoryView& repository, const std::string& name,
const std::string& start_point, bool checkout, std::string& error);
bool createTag(RepositoryView& repository, const std::string& name,
const std::string& target, std::string& error);
bool addRemote(RepositoryView& repository, const std::string& name,
const std::string& url, std::string& error);
bool addWorktree(RepositoryView& repository, const std::string& path,
const std::string& branch, std::string& error);
bool addSubmodule(RepositoryView& repository, const std::string& url,
const std::string& path, std::string& error);
bool updateSubmodule(RepositoryView& repository, const std::string& name, std::string& error);
std::string worktreePath(RepositoryView& repository, const std::string& name, std::string& error);
bool loadCommitChanges(RepositoryView& repository, int commit_index, std::string& error);
bool captureGit(RepositoryView& repository, const std::vector<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 openRepository(RepositoryView &repository, const std::string &path, std::string &error);
bool initializeRepository(RepositoryView &repository, const std::string &path,
const std::string &initial_branch, std::string &error);
bool cloneRepository(RepositoryView &repository, const std::string &url,
const std::string &path, bool shallow, std::string &error);
bool reload(RepositoryView &repository, std::string &error);
bool loadMoreCommits(RepositoryView &repository, size_t page_size, std::string &error);
bool checkoutBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool fetch(RepositoryView &repository, const std::string &remote, std::string &error);
bool pull(RepositoryView &repository, int mode, std::string &error);
bool push(RepositoryView &repository, std::string &error);
bool pushBranch(RepositoryView &repository, const std::string &branch, std::string &error);
bool stash(RepositoryView &repository, std::string &error);
bool popStash(RepositoryView &repository, std::string &error);
bool undoCommit(RepositoryView &repository, std::string &error);
bool redoCommit(RepositoryView &repository, std::string &error);
bool stageAll(RepositoryView &repository, std::string &error);
bool unstageAll(RepositoryView &repository, std::string &error);
bool stageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool unstageFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardFile(RepositoryView &repository, const std::string &path, std::string &error);
bool discardAll(RepositoryView &repository, std::string &error);
bool commit(RepositoryView &repository, const std::string &summary,
const std::string &description, bool amend, std::string &error);
bool createBranch(RepositoryView &repository, const std::string &name,
const std::string &start_point, bool checkout, std::string &error);
bool createTag(RepositoryView &repository, const std::string &name,
const std::string &target, std::string &error);
bool addRemote(RepositoryView &repository, const std::string &name,
const std::string &url, std::string &error);
bool addWorktree(RepositoryView &repository, const std::string &path,
const std::string &branch, std::string &error);
bool addSubmodule(RepositoryView &repository, const std::string &url,
const std::string &path, std::string &error);
bool updateSubmodule(RepositoryView &repository, const std::string &name, std::string &error);
std::string worktreePath(RepositoryView &repository, const std::string &name, std::string &error);
bool loadCommitChanges(RepositoryView &repository, int commit_index, std::string &error);
bool loadCommitFiles(RepositoryView &repository, int commit_index, std::string &error);
bool captureGit(RepositoryView &repository, const std::vector<std::string> &arguments,
std::string &output, std::string &error);
bool applyPatch(RepositoryView &repository, const std::string &patch, bool cached,
bool reverse, std::string &error);
private:
static std::string lastError(const char* fallback);
static std::string lastError(const char *fallback);
static std::string formatTime(git_time_t value, int offset_minutes);
void readBranches(RepositoryView& repository, git_branch_t type, std::vector<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);
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);
};

View File

@@ -1,72 +1,111 @@
#include "user_data.h"
extern "C" {
#include <izo/Paths.hpp>
extern "C"
{
#include <ikv.h>
}
#include <algorithm>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <utility>
namespace {
std::filesystem::path roaming_directory() {
#ifdef _WIN32
if (const char* appdata = std::getenv("APPDATA")) return appdata;
#endif
if (const char* home = std::getenv("HOME")) return std::filesystem::path(home) / ".config";
return std::filesystem::temp_directory_path();
namespace
{
std::filesystem::path roaming_directory()
{
if (const auto config = izo::GetKnownPath(izo::KnownPath::Config); !config.empty())
return config;
if (const auto temporary = izo::GetKnownPath(izo::KnownPath::Temporary); !temporary.empty())
return temporary;
return std::filesystem::temp_directory_path();
}
const ikv_node_t *object_value(const ikv_node_t *object, const char *key, ikv_type_t type)
{
const ikv_node_t *value = object ? ikv_object_get(object, key) : nullptr;
return value && ikv_node_type(value) == type ? value : nullptr;
}
}
const ikv_node_t* object_value(const ikv_node_t* object, const char* key, ikv_type_t type) {
const ikv_node_t* value = object ? ikv_object_get(object, key) : nullptr;
return value && ikv_node_type(value) == type ? value : nullptr;
}
}
UserData::UserData() {
UserData::UserData()
{
directory_ = roaming_directory() / "Identity" / "Gitree";
std::filesystem::create_directories(directory_);
imgui_ini_path_ = (directory_ / "imgui.ini").string();
load();
}
UserData::~UserData() {
UserData::~UserData()
{
save();
}
void UserData::load() {
const std::filesystem::path data_path = directory_ / "user_data.ikv";
if (ikv_node_t* root = ikv_parse_file(data_path.string().c_str())) {
if (const ikv_node_t* settings = object_value(root, "settings", IKV_OBJECT)) {
if (const ikv_node_t* value = object_value(settings, "sidebar_width", IKV_FLOAT))
std::filesystem::path UserData::dataPath() const
{
return directory_ / "user_data.ikv2b";
}
void UserData::removeLegacyFiles() const
{
std::error_code error;
for (const char *name : {"imgui.ini", "settings.ini", "history.txt", "session.txt", "user_data.ikv"})
std::filesystem::remove(directory_ / name, error);
}
void UserData::load()
{
const std::filesystem::path binary_path = dataPath();
if (ikv_node_t *root = ikvb_parse_file(binary_path.string().c_str()))
{
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
{
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t* value = object_value(settings, "details_width", IKV_FLOAT))
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t* value = object_value(settings, "pull_mode", IKV_INT))
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t* heights = object_value(settings, "sidebar_sections", IKV_ARRAY)) {
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
}
}
if (const ikv_node_t* history = object_value(root, "recently_closed", IKV_ARRAY)) {
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 12);
for (uint32_t index = 0; index < count; ++index) {
const char* path = ikv_as_string(ikv_array_get(history, index));
if (path && *path) recently_closed_.emplace_back(path);
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(history, index));
if (path && *path)
recently_closed_.emplace_back(path);
}
}
if (const ikv_node_t* session = object_value(root, "session", IKV_OBJECT)) {
if (const ikv_node_t* active = object_value(session, "active_tab", IKV_INT))
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(recent, index));
if (path && *path)
recent_repositories_.emplace_back(path);
}
}
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
{
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
if (const ikv_node_t* tabs = object_value(session, "tabs", IKV_ARRAY)) {
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index) {
const char* path = ikv_as_string(ikv_array_get(tabs, index));
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
{
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
{
const char *path = ikv_as_string(ikv_array_get(tabs, index));
open_repositories_.emplace_back(path ? path : "");
}
}
@@ -75,81 +114,212 @@ void UserData::load() {
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
for (float& height : sidebar_section_heights_) height = std::clamp(height, 42.0f, 500.0f);
if (open_repositories_.empty()) active_repository_ = 0;
else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
return;
}
const std::filesystem::path text_data_path = directory_ / "user_data.ikv";
if (ikv_node_t *root = ikv_parse_file(text_data_path.string().c_str()))
{
if (const ikv_node_t *settings = object_value(root, "settings", IKV_OBJECT))
{
if (const ikv_node_t *value = object_value(settings, "sidebar_width", IKV_FLOAT))
sidebar_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "details_width", IKV_FLOAT))
details_width_ = static_cast<float>(ikv_as_float(value));
if (const ikv_node_t *value = object_value(settings, "pull_mode", IKV_INT))
pull_mode_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *value = object_value(settings, "zoom_percent", IKV_INT))
zoom_percent_ = static_cast<int>(ikv_as_int(value));
if (const ikv_node_t *heights = object_value(settings, "sidebar_sections", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(
ikv_array_size(heights), static_cast<uint32_t>(sidebar_section_heights_.size()));
for (uint32_t index = 0; index < count; ++index)
sidebar_section_heights_[index] = static_cast<float>(ikv_as_float(ikv_array_get(heights, index)));
}
}
if (const ikv_node_t *history = object_value(root, "recently_closed", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(history), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(history, index));
if (path && *path)
recently_closed_.emplace_back(path);
}
}
if (const ikv_node_t *recent = object_value(root, "recent_repositories", IKV_ARRAY))
{
const uint32_t count = std::min<uint32_t>(ikv_array_size(recent), 100);
for (uint32_t index = 0; index < count; ++index)
{
const char *path = ikv_as_string(ikv_array_get(recent, index));
if (path && *path)
recent_repositories_.emplace_back(path);
}
}
if (const ikv_node_t *session = object_value(root, "session", IKV_OBJECT))
{
if (const ikv_node_t *active = object_value(session, "active_tab", IKV_INT))
active_repository_ = static_cast<size_t>(std::max<int64_t>(0, ikv_as_int(active)));
if (const ikv_node_t *tabs = object_value(session, "tabs", IKV_ARRAY))
{
for (uint32_t index = 0; index < ikv_array_size(tabs); ++index)
{
const char *path = ikv_as_string(ikv_array_get(tabs, index));
open_repositories_.emplace_back(path ? path : "");
}
}
}
ikv_free(root);
sidebar_width_ = std::clamp(sidebar_width_, 180.0f, 520.0f);
details_width_ = std::clamp(details_width_, 280.0f, 650.0f);
pull_mode_ = std::clamp(pull_mode_, 0, 3);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
save();
return;
}
// Import the original files once when upgrading an existing installation.
std::ifstream settings(directory_ / "settings.ini");
std::string key;
while (settings >> key) {
if (key == "sidebar_width") settings >> sidebar_width_;
else if (key == "details_width") settings >> details_width_;
else if (key == "pull_mode") settings >> pull_mode_;
else if (key.rfind("sidebar_section_", 0) == 0) {
while (settings >> key)
{
if (key == "sidebar_width")
settings >> sidebar_width_;
else if (key == "details_width")
settings >> details_width_;
else if (key == "pull_mode")
settings >> pull_mode_;
else if (key == "zoom_percent")
settings >> zoom_percent_;
else if (key.rfind("sidebar_section_", 0) == 0)
{
const size_t index = static_cast<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);
zoom_percent_ = std::clamp(zoom_percent_, 80, 200);
for (float &height : sidebar_section_heights_)
height = std::clamp(height, 42.0f, 500.0f);
std::ifstream history(directory_ / "history.txt");
std::string path;
while (history >> std::quoted(path)) {
if (!path.empty()) recently_closed_.push_back(path);
if (recently_closed_.size() == 12) break;
while (history >> std::quoted(path))
{
if (!path.empty())
recently_closed_.push_back(path);
if (recently_closed_.size() == 100)
break;
}
recent_repositories_ = recently_closed_;
std::ifstream session(directory_ / "session.txt");
session >> active_repository_;
while (session >> std::quoted(path)) open_repositories_.push_back(path);
if (open_repositories_.empty()) active_repository_ = 0;
else active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
while (session >> std::quoted(path))
open_repositories_.push_back(path);
if (open_repositories_.empty())
active_repository_ = 0;
else
active_repository_ = std::min(active_repository_, open_repositories_.size() - 1);
save();
}
void UserData::addRecentlyClosed(const std::string& path) {
if (path.empty()) return;
void UserData::addRecentRepository(const std::string &path)
{
if (path.empty())
return;
std::erase(recent_repositories_, path);
recent_repositories_.insert(recent_repositories_.begin(), path);
if (recent_repositories_.size() > 100)
recent_repositories_.resize(100);
save();
}
void UserData::addRecentlyClosed(const std::string &path)
{
if (path.empty())
return;
std::erase(recent_repositories_, path);
recent_repositories_.insert(recent_repositories_.begin(), path);
if (recent_repositories_.size() > 100)
recent_repositories_.resize(100);
std::erase(recently_closed_, path);
recently_closed_.insert(recently_closed_.begin(), path);
if (recently_closed_.size() > 12) recently_closed_.resize(12);
if (recently_closed_.size() > 100)
recently_closed_.resize(100);
save();
}
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository) {
std::string UserData::takeRecentlyClosed()
{
if (recently_closed_.empty())
return {};
std::string path = std::move(recently_closed_.front());
recently_closed_.erase(recently_closed_.begin());
save();
return path;
}
void UserData::setRepositorySession(std::vector<std::string> paths, size_t active_repository)
{
open_repositories_ = std::move(paths);
active_repository_ = open_repositories_.empty()
? 0
: std::min(active_repository, open_repositories_.size() - 1);
? 0
: std::min(active_repository, open_repositories_.size() - 1);
save();
}
void UserData::save() const {
void UserData::save() const
{
std::filesystem::create_directories(directory_);
ikv_node_t* root = ikv_create_object("gitree");
if (!root) return;
ikv_node_t *root = ikv_create_object("gitree");
if (!root)
return;
ikv_node_t* settings = ikv_object_add_object(root, "settings");
ikv_node_t *settings = ikv_object_add_object(root, "settings");
ikv_object_set_float(settings, "sidebar_width", sidebar_width_);
ikv_object_set_float(settings, "details_width", details_width_);
ikv_object_set_int(settings, "pull_mode", pull_mode_);
ikv_node_t* heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT);
for (const float height : sidebar_section_heights_) ikv_array_add_float(heights, height);
ikv_object_set_int(settings, "zoom_percent", zoom_percent_);
ikv_node_t *heights = ikv_object_add_array(settings, "sidebar_sections", IKV_FLOAT);
for (const float height : sidebar_section_heights_)
ikv_array_add_float(heights, height);
ikv_node_t* history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
for (const auto& path : recently_closed_) ikv_array_add_string(history, path.c_str());
ikv_node_t *history = ikv_object_add_array(root, "recently_closed", IKV_STRING);
for (const auto &path : recently_closed_)
ikv_array_add_string(history, path.c_str());
ikv_node_t* session = ikv_object_add_object(root, "session");
ikv_node_t *recent = ikv_object_add_array(root, "recent_repositories", IKV_STRING);
for (const auto &path : recent_repositories_)
ikv_array_add_string(recent, path.c_str());
ikv_node_t *session = ikv_object_add_object(root, "session");
ikv_object_set_int(session, "active_tab", static_cast<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_node_t *tabs = ikv_object_add_array(session, "tabs", IKV_STRING);
for (const auto &path : open_repositories_)
ikv_array_add_string(tabs, path.c_str());
ikv_write_file((directory_ / "user_data.ikv").string().c_str(), root);
ikvb_write_file_version(dataPath().string().c_str(), root, IKV_VERSION_2);
ikv_free(root);
removeLegacyFiles();
}

View File

@@ -5,34 +5,49 @@
#include <string>
#include <vector>
class UserData {
class UserData
{
public:
UserData();
~UserData();
[[nodiscard]] const std::filesystem::path& directory() const { return directory_; }
[[nodiscard]] const std::string& imguiIniPath() const { return imgui_ini_path_; }
[[nodiscard]] const std::vector<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]] int pullMode() const { return pull_mode_; }
[[nodiscard]] int zoomPercent() const { return zoom_percent_; }
void setSidebarWidth(float width) { sidebar_width_ = width; }
void setDetailsWidth(float width) { details_width_ = width; }
void setSidebarSectionHeight(size_t index, float height) { sidebar_section_heights_.at(index) = height; }
void setPullMode(int mode) { pull_mode_ = mode; save(); }
void addRecentlyClosed(const std::string& path);
void setPullMode(int mode)
{
pull_mode_ = mode;
save();
}
void setZoomPercent(int percent)
{
zoom_percent_ = percent;
save();
}
void addRecentRepository(const std::string &path);
void addRecentlyClosed(const std::string &path);
std::string takeRecentlyClosed();
void setRepositorySession(std::vector<std::string> paths, size_t active_repository);
void save() const;
private:
void load();
[[nodiscard]] std::filesystem::path dataPath() const;
void removeLegacyFiles() const;
std::filesystem::path directory_;
std::string imgui_ini_path_;
std::vector<std::string> recent_repositories_;
std::vector<std::string> recently_closed_;
std::vector<std::string> open_repositories_;
size_t active_repository_ = 0;
@@ -40,4 +55,5 @@ private:
float details_width_ = 368.0f;
std::array<float, 4> sidebar_section_heights_ = {110.0f, 220.0f, 90.0f, 150.0f};
int pull_mode_ = 1;
int zoom_percent_ = 100;
};

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;
}
};

View File

@@ -1,17 +1,22 @@
#include "ui/diff_viewer.h"
#include "managers/avatar_cache.h"
#include "managers/git_manager.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <IconsTabler.h>
#include <imgui.h>
#include <izo/Dialogs.hpp>
#include <algorithm>
#include <cctype>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <initializer_list>
#include <sstream>
#include <string_view>
namespace {
float scaled(float value, float scale) { return value * scale; }
@@ -38,46 +43,455 @@ void parseRange(const std::string& header, char marker, int& line) {
}
}
ImU32 syntaxColor(const std::string& text) {
const size_t first = text.find_first_not_of(" \t");
if (first == std::string::npos) return IM_COL32(210, 214, 220, 255);
const std::string_view value(text.c_str() + first, text.size() - first);
if (value.starts_with("//") || value.starts_with("# ")) return IM_COL32(129, 184, 125, 255);
if (value.starts_with('#')) return IM_COL32(205, 157, 222, 255);
static constexpr const char* keywords[] = {
"class ", "struct ", "enum ", "if ", "else", "for ", "while ", "return ",
"const ", "static ", "void ", "bool ", "int ", "float ", "auto ", "namespace "
enum class SyntaxLanguage { plain, c, cpp, csharp, lua, python, javascript };
enum class SyntaxContinuation { none, block_comment, lua_comment, python_single, python_double, template_string };
struct SyntaxState {
SyntaxContinuation continuation = SyntaxContinuation::none;
};
[[maybe_unused]] ImU32 blameColor(std::string_view hash, int alpha = 255) {
static constexpr ImU32 colors[] = {
IM_COL32(24, 181, 204, 255), IM_COL32(73, 123, 235, 255), IM_COL32(200, 64, 200, 255),
IM_COL32(239, 79, 89, 255), IM_COL32(255, 122, 41, 255), IM_COL32(240, 186, 46, 255),
IM_COL32(64, 186, 128, 255),
};
for (const char* keyword : keywords)
if (value.starts_with(keyword)) return IM_COL32(124, 177, 228, 255);
if (value.find('"') != std::string_view::npos || value.find('\'') != std::string_view::npos)
return IM_COL32(226, 166, 140, 255);
return IM_COL32(218, 221, 226, 255);
uint32_t value = 2166136261u;
for (const unsigned char character : hash) value = (value ^ character) * 16777619u;
return (colors[value % std::size(colors)] & ~IM_COL32_A_MASK) | (static_cast<ImU32>(alpha) << IM_COL32_A_SHIFT);
}
[[maybe_unused]] SyntaxLanguage languageForPath(const std::string& path) {
std::string extension = std::filesystem::path(path).extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(), [](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
if (extension == ".c") return SyntaxLanguage::c;
if (extension == ".h" || extension == ".hh" || extension == ".hpp" || extension == ".hxx" ||
extension == ".cc" || extension == ".cpp" || extension == ".cxx") return SyntaxLanguage::cpp;
if (extension == ".cs") return SyntaxLanguage::csharp;
if (extension == ".lua") return SyntaxLanguage::lua;
if (extension == ".py" || extension == ".pyw") return SyntaxLanguage::python;
if (extension == ".js" || extension == ".jsx" || extension == ".mjs" || extension == ".cjs")
return SyntaxLanguage::javascript;
return SyntaxLanguage::plain;
}
bool wordIs(std::string_view word, std::initializer_list<std::string_view> values) {
return std::find(values.begin(), values.end(), word) != values.end();
}
bool isKeyword(SyntaxLanguage language, std::string_view word) {
static constexpr std::string_view common[] = {
"break", "case", "continue", "default", "do", "else", "for", "if", "return", "switch", "while"
};
if (std::find(std::begin(common), std::end(common), word) != std::end(common)) return true;
switch (language) {
case SyntaxLanguage::c:
return wordIs(word, {"const", "enum", "extern", "goto", "register", "sizeof", "static", "struct", "typedef", "union", "volatile"});
case SyntaxLanguage::cpp:
return wordIs(word, {"alignas", "auto", "class", "concept", "const", "constexpr", "consteval", "constinit", "co_await", "co_return", "co_yield", "decltype", "delete", "enum", "explicit", "export", "extern", "friend", "inline", "mutable", "namespace", "new", "noexcept", "operator", "override", "private", "protected", "public", "requires", "sizeof", "static", "struct", "template", "this", "thread_local", "throw", "try", "typedef", "typename", "union", "using", "virtual", "volatile"});
case SyntaxLanguage::csharp:
return wordIs(word, {"abstract", "as", "async", "await", "base", "class", "const", "delegate", "enum", "event", "explicit", "extern", "finally", "fixed", "foreach", "implicit", "in", "interface", "internal", "is", "lock", "namespace", "new", "operator", "out", "override", "params", "private", "protected", "public", "readonly", "record", "ref", "sealed", "sizeof", "stackalloc", "static", "struct", "this", "throw", "try", "typeof", "unchecked", "unsafe", "using", "virtual", "volatile", "where", "yield"});
case SyntaxLanguage::lua:
return wordIs(word, {"and", "elseif", "end", "false", "function", "goto", "in", "local", "nil", "not", "or", "repeat", "then", "true", "until"});
case SyntaxLanguage::python:
return wordIs(word, {"and", "as", "assert", "async", "await", "class", "def", "del", "elif", "except", "False", "finally", "from", "global", "import", "in", "is", "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "True", "try", "with", "yield"});
case SyntaxLanguage::javascript:
return wordIs(word, {"async", "await", "catch", "class", "const", "debugger", "delete", "export", "extends", "false", "finally", "from", "function", "get", "import", "in", "instanceof", "let", "new", "null", "of", "set", "static", "super", "this", "throw", "true", "try", "typeof", "undefined", "var", "void", "with", "yield"});
default:
return false;
}
}
bool isTypeWord(SyntaxLanguage language, std::string_view word) {
if (language == SyntaxLanguage::python || language == SyntaxLanguage::lua || language == SyntaxLanguage::plain)
return false;
return wordIs(word, {"bool", "byte", "char", "decimal", "double", "dynamic", "float", "int", "int8_t", "int16_t", "int32_t", "int64_t", "long", "nint", "nuint", "object", "sbyte", "short", "signed", "size_t", "string", "uint", "uint8_t", "uint16_t", "uint32_t", "uint64_t", "ulong", "unsigned", "ushort", "var", "void", "wchar_t"});
}
bool isBuiltin(SyntaxLanguage language, std::string_view word) {
switch (language) {
case SyntaxLanguage::cpp:
return wordIs(word, {"std", "move", "forward", "make_shared", "make_unique", "optional", "string_view", "vector"});
case SyntaxLanguage::csharp:
return wordIs(word, {"Console", "DateTime", "Dictionary", "Enumerable", "List", "Task", "ValueTask"});
case SyntaxLanguage::lua:
return wordIs(word, {"assert", "error", "ipairs", "io", "math", "os", "pairs", "pcall", "print", "require", "string", "table", "tonumber", "tostring", "type"});
case SyntaxLanguage::python:
return wordIs(word, {"abs", "all", "any", "dict", "enumerate", "filter", "float", "int", "len", "list", "map", "max", "min", "object", "open", "print", "range", "reversed", "set", "str", "sum", "super", "tuple", "zip"});
case SyntaxLanguage::javascript:
return wordIs(word, {"Array", "Boolean", "console", "Date", "Error", "JSON", "Map", "Math", "Number", "Object", "process", "Promise", "RegExp", "require", "Set", "String", "Symbol"});
default:
return false;
}
}
bool isLiteral(std::string_view word) {
return wordIs(word, {"false", "False", "nil", "null", "nullptr", "None", "true", "True", "undefined"});
}
bool isDeclarationKeyword(std::string_view word) {
return wordIs(word, {"class", "concept", "delegate", "enum", "interface", "namespace", "record", "struct", "type"});
}
bool isFunctionDeclarationKeyword(std::string_view word) {
return word == "def" || word == "function";
}
bool isMacroName(std::string_view word) {
bool has_letter = false;
for (const unsigned char character : word) {
if (std::isalpha(character)) {
has_letter = true;
if (std::islower(character)) return false;
} else if (!std::isdigit(character) && character != '_') {
return false;
}
}
return has_letter && word.size() > 1;
}
[[maybe_unused]] void drawSyntaxText(ImDrawList* draw, ImVec2 position, const std::string& text,
SyntaxLanguage language, SyntaxState& state) {
constexpr ImU32 normal = IM_COL32(218, 221, 226, 255);
constexpr ImU32 keyword = IM_COL32(198, 139, 230, 255);
constexpr ImU32 type = IM_COL32(91, 198, 190, 255);
constexpr ImU32 string = IM_COL32(226, 166, 140, 255);
constexpr ImU32 number = IM_COL32(181, 206, 126, 255);
constexpr ImU32 comment = IM_COL32(112, 153, 105, 255);
constexpr ImU32 function = IM_COL32(220, 199, 128, 255);
constexpr ImU32 preprocessor = IM_COL32(205, 157, 222, 255);
constexpr ImU32 member = IM_COL32(122, 184, 225, 255);
constexpr ImU32 builtin = IM_COL32(103, 172, 232, 255);
constexpr ImU32 constant = IM_COL32(214, 139, 102, 255);
constexpr ImU32 operator_color = IM_COL32(105, 180, 210, 255);
constexpr ImU32 decorator = IM_COL32(226, 190, 105, 255);
constexpr ImU32 macro = IM_COL32(215, 128, 180, 255);
const auto drawSpan = [&](size_t begin, size_t end, ImU32 color) {
if (end <= begin) return;
const char* first = text.data() + begin;
const char* last = text.data() + end;
draw->AddText(position, color, first, last);
position.x += ImGui::CalcTextSize(first, last, false).x;
};
if (language == SyntaxLanguage::plain) {
drawSpan(0, text.size(), normal);
return;
}
const size_t first_non_space = text.find_first_not_of(" \t");
if ((language == SyntaxLanguage::c || language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
first_non_space != std::string::npos && text[first_non_space] == '#') {
drawSpan(0, first_non_space, normal);
drawSpan(first_non_space, text.size(), preprocessor);
return;
}
size_t cursor = 0;
std::string_view previous_word;
while (cursor < text.size()) {
if (state.continuation == SyntaxContinuation::block_comment) {
const size_t end = text.find("*/", cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; }
drawSpan(cursor, end + 2, comment);
cursor = end + 2;
state.continuation = SyntaxContinuation::none;
continue;
}
if (state.continuation == SyntaxContinuation::lua_comment) {
const size_t end = text.find("]]", cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), comment); return; }
drawSpan(cursor, end + 2, comment);
cursor = end + 2;
state.continuation = SyntaxContinuation::none;
continue;
}
if (state.continuation == SyntaxContinuation::python_single ||
state.continuation == SyntaxContinuation::python_double) {
const std::string_view delimiter = state.continuation == SyntaxContinuation::python_single ? "'''" : "\"\"\"";
const size_t end = text.find(delimiter, cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; }
drawSpan(cursor, end + delimiter.size(), string);
cursor = end + delimiter.size();
state.continuation = SyntaxContinuation::none;
continue;
}
if (state.continuation == SyntaxContinuation::template_string) {
const size_t end = text.find('`', cursor);
if (end == std::string::npos) { drawSpan(cursor, text.size(), string); return; }
drawSpan(cursor, end + 1, string);
cursor = end + 1;
state.continuation = SyntaxContinuation::none;
continue;
}
const bool slash_comments = language == SyntaxLanguage::c || language == SyntaxLanguage::cpp ||
language == SyntaxLanguage::csharp || language == SyntaxLanguage::javascript;
if (slash_comments && text.compare(cursor, 2, "//") == 0) {
drawSpan(cursor, text.size(), comment);
return;
}
if (slash_comments && text.compare(cursor, 2, "/*") == 0) {
const size_t end = text.find("*/", cursor + 2);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), comment);
state.continuation = SyntaxContinuation::block_comment;
return;
}
drawSpan(cursor, end + 2, comment);
cursor = end + 2;
continue;
}
if (language == SyntaxLanguage::python && text[cursor] == '#') {
drawSpan(cursor, text.size(), comment);
return;
}
if (language == SyntaxLanguage::lua && text.compare(cursor, 4, "--[[") == 0) {
const size_t end = text.find("]]", cursor + 4);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), comment);
state.continuation = SyntaxContinuation::lua_comment;
return;
}
drawSpan(cursor, end + 2, comment);
cursor = end + 2;
continue;
}
if (language == SyntaxLanguage::lua && text.compare(cursor, 2, "--") == 0) {
drawSpan(cursor, text.size(), comment);
return;
}
if (language == SyntaxLanguage::python &&
(text.compare(cursor, 3, "'''") == 0 || text.compare(cursor, 3, "\"\"\"") == 0)) {
const std::string_view delimiter(text.data() + cursor, 3);
const size_t end = text.find(delimiter, cursor + 3);
if (end == std::string::npos) {
drawSpan(cursor, text.size(), string);
state.continuation = delimiter[0] == '\'' ? SyntaxContinuation::python_single : SyntaxContinuation::python_double;
return;
}
drawSpan(cursor, end + 3, string);
cursor = end + 3;
continue;
}
if (text[cursor] == '\'' || text[cursor] == '"' ||
(language == SyntaxLanguage::javascript && text[cursor] == '`')) {
const char quote = text[cursor];
size_t end = cursor + 1;
bool escaped = false;
for (; end < text.size(); ++end) {
if (!escaped && text[end] == quote) { ++end; break; }
escaped = !escaped && text[end] == '\\';
if (text[end] != '\\') escaped = false;
}
const bool closed = end <= text.size() && end > cursor + 1 && text[end - 1] == quote;
drawSpan(cursor, end, string);
if (quote == '`' && !closed) state.continuation = SyntaxContinuation::template_string;
cursor = end;
continue;
}
if (std::isdigit(static_cast<unsigned char>(text[cursor]))) {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) ||
text[end] == '.' || text[end] == '_')) ++end;
drawSpan(cursor, end, number);
cursor = end;
continue;
}
if (text[cursor] == '@' && cursor + 1 < text.size() &&
(std::isalpha(static_cast<unsigned char>(text[cursor + 1])) || text[cursor + 1] == '_')) {
size_t end = cursor + 2;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) ||
text[end] == '_' || text[end] == '.')) ++end;
drawSpan(cursor, end, decorator);
cursor = end;
continue;
}
if (std::isalpha(static_cast<unsigned char>(text[cursor])) || text[cursor] == '_') {
size_t end = cursor + 1;
while (end < text.size() && (std::isalnum(static_cast<unsigned char>(text[end])) || text[end] == '_')) ++end;
const std::string_view word(text.data() + cursor, end - cursor);
size_t next = end;
while (next < text.size() && std::isspace(static_cast<unsigned char>(text[next]))) ++next;
size_t previous = cursor;
while (previous > 0 && std::isspace(static_cast<unsigned char>(text[previous - 1]))) --previous;
const bool member_access = previous > 0 && (text[previous - 1] == '.' ||
(text[previous - 1] == '>' && previous > 1 && text[previous - 2] == '-') ||
(text[previous - 1] == ':' && previous > 1 && text[previous - 2] == ':'));
const bool declared_name = isDeclarationKeyword(previous_word);
const bool declared_function = isFunctionDeclarationKeyword(previous_word);
const bool capitalized_type =
(language == SyntaxLanguage::cpp || language == SyntaxLanguage::csharp) &&
std::isupper(static_cast<unsigned char>(word.front())) && !member_access &&
(next >= text.size() || text[next] != '(');
const bool likely_type = declared_name || isTypeWord(language, word) ||
capitalized_type;
const ImU32 color = isLiteral(word) ? constant : isKeyword(language, word) ? keyword :
declared_function ? function : declared_name || likely_type ? type :
isMacroName(word) ? macro : isBuiltin(language, word) ? builtin : member_access ?
(next < text.size() && text[next] == '(' ? function : member) :
next < text.size() && text[next] == '(' ? function : normal;
drawSpan(cursor, end, color);
previous_word = word;
cursor = end;
continue;
}
if (std::string_view("+-*/%=!<>&|^~?:").find(text[cursor]) != std::string_view::npos) {
size_t end = cursor + 1;
while (end < text.size() &&
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos) ++end;
drawSpan(cursor, end, operator_color);
cursor = end;
continue;
}
size_t end = cursor + 1;
while (end < text.size()) {
const unsigned char value = static_cast<unsigned char>(text[end]);
const bool token_start = std::isalnum(value) || text[end] == '_' || text[end] == '\'' ||
text[end] == '"' || (language == SyntaxLanguage::javascript && text[end] == '`') ||
(slash_comments && text[end] == '/') || (language == SyntaxLanguage::python && text[end] == '#') ||
(language == SyntaxLanguage::lua && text[end] == '-') || text[end] == '@' ||
std::string_view("+-*/%=!<>&|^~?:").find(text[end]) != std::string_view::npos;
if (token_start) break;
++end;
}
drawSpan(cursor, end, normal);
cursor = end;
}
}
bool compactButton(const char* label, bool active = false) {
if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.25f, 0.43f, 1.0f));
const bool clicked = ImGui::SmallButton(label);
const bool icon_only = label && std::strlen(label) <= 4;
const bool clicked = icon_only
? ImGui::Button(label, {ImGui::GetFrameHeight(), ImGui::GetFrameHeight()})
: ImGui::SmallButton(label);
if (active) ImGui::PopStyleColor();
return clicked;
}
std::string joinLines(const std::vector<std::string>& lines) {
std::string text;
for (size_t index = 0; index < lines.size(); ++index) {
if (index) text += '\n';
text += lines[index];
}
return text;
}
bool drawSelectableTextBlock(const char* id, const std::string& text, const ImVec2& size) {
std::vector<char> buffer(text.begin(), text.end());
buffer.push_back('\0');
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.06f, 0.07f, 0.09f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.18f, 0.20f, 0.24f, 1.0f));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {4.0f, 4.0f});
const bool changed = ImGui::InputTextMultiline(id, buffer.data(), buffer.size(), size,
ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_AllowTabInput);
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(2);
return changed;
}
struct MinimapEntry {
size_t length = 0;
ImU32 color = IM_COL32(120, 126, 136, 255);
};
void drawMinimap(const std::vector<MinimapEntry>& entries, float scale,
float rendered_content_height, float visible_start, float visible_height) {
const ImVec2 minimum = ImGui::GetCursorScreenPos();
const ImVec2 size = ImGui::GetContentRegionAvail();
ImGui::InvisibleButton("##code_minimap", size);
ImDrawList* draw = ImGui::GetWindowDrawList();
draw->AddRectFilled(minimum, {minimum.x + size.x, minimum.y + size.y}, IM_COL32(38, 40, 47, 255));
draw->AddRectFilled({minimum.x + size.x - scaled(10.0f, scale), minimum.y},
{minimum.x + size.x, minimum.y + size.y}, IM_COL32(64, 66, 74, 255));
if (entries.empty() || size.y <= 2.0f) return;
const float content_left = minimum.x + scaled(4.0f, scale);
const float content_right = minimum.x + size.x - scaled(14.0f, scale);
const float content_width = std::max(1.0f, content_right - content_left);
const float line_step = size.y / static_cast<float>(entries.size());
const float line_height = std::max(1.0f, std::min(scaled(2.0f, scale), line_step));
constexpr float max_reference_length = 160.0f;
for (size_t index = 0; index < entries.size(); ++index) {
const float normalized = std::clamp(static_cast<float>(entries[index].length) / max_reference_length, 0.08f, 1.0f);
const float width = content_width * normalized;
const float y = minimum.y + index * line_step;
draw->AddRectFilled({content_left, y}, {content_left + width, y + line_height},
entries[index].color, scaled(1.0f, scale));
}
const float content_height = std::max(rendered_content_height, 1.0f);
const float clamped_visible_height = std::clamp(visible_height, 0.0f, content_height);
const float clamped_visible_start = std::clamp(visible_start, 0.0f, std::max(0.0f, content_height - clamped_visible_height));
const float viewport_height = std::clamp(size.y * (clamped_visible_height / content_height),
scaled(18.0f, scale), size.y);
const float viewport_y = minimum.y + size.y * (clamped_visible_start / content_height);
draw->AddRectFilled({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 45), scaled(2.0f, scale));
draw->AddRect({minimum.x + scaled(1.0f, scale), viewport_y},
{minimum.x + size.x - scaled(11.0f, scale), viewport_y + viewport_height},
IM_COL32(120, 146, 198, 160), scaled(2.0f, scale));
}
void drawCodeLine(const std::string& text, SyntaxLanguage language, SyntaxState& syntax,
float scale, ImU32 background = IM_COL32(0, 0, 0, 0), float left_gutter = 0.0f,
float minimum_width = 0.0f, float custom_row_height = 0.0f) {
const float row_height = custom_row_height > 0.0f ? custom_row_height : scaled(21.0f, scale);
const ImVec2 start = ImGui::GetCursorScreenPos();
const float width = std::max(minimum_width, ImGui::CalcTextSize(text.c_str()).x + left_gutter + scaled(12.0f, scale));
ImGui::InvisibleButton("##code_line", {width, row_height});
const ImVec2 minimum = ImGui::GetItemRectMin();
const ImVec2 maximum = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
if ((background & IM_COL32_A_MASK) != 0)
draw->AddRectFilled(minimum, maximum, background);
drawSyntaxText(draw, {start.x + left_gutter, start.y + scaled(2.0f, scale)}, text, language, syntax);
}
void drawCodeLineNumber(int value, float x, float y, ImU32 color) {
if (value <= 0) return;
const std::string text = std::to_string(value);
ImGui::GetWindowDrawList()->AddText({x, y}, color, text.c_str());
}
}
void DiffViewer::open(RepositoryView& repository, GitManager& manager, const std::string& path,
bool staged, std::string& notice) {
path_ = path;
commit_id_.clear();
staged_ = staged;
mode_ = Mode::diff;
reload(repository, manager, notice);
}
void DiffViewer::openCommit(RepositoryView& repository, GitManager& manager,
const std::string& path, const std::string& commit_id, std::string& notice) {
path_ = path;
commit_id_ = commit_id;
staged_ = false;
mode_ = Mode::diff;
reload(repository, manager, notice);
if (hunks_.empty()) {
mode_ = Mode::file;
loadSupplement(repository, manager, mode_, notice);
}
}
void DiffViewer::close() {
path_.clear();
commit_id_.clear();
file_header_.clear();
hunks_.clear();
file_lines_.clear();
blame_lines_.clear();
history_lines_.clear();
history_entries_.clear();
}
void DiffViewer::parseDiff(const std::string& text) {
@@ -118,16 +532,97 @@ void DiffViewer::parseDiff(const std::string& text) {
}
}
void DiffViewer::parseBlame(const std::string& text) {
blame_lines_.clear();
const std::vector<std::string> lines = splitLines(text);
std::string previous_hash;
for (size_t index = 0; index < lines.size();) {
std::istringstream header(lines[index++]);
BlameLine line;
int original_line = 0;
if (!(header >> line.hash >> original_line >> line.line_number)) continue;
if (line.hash.size() < 8 || !std::all_of(line.hash.begin(), line.hash.end(), [](unsigned char value) {
return std::isxdigit(value) != 0;
})) continue;
std::time_t author_time = 0;
while (index < lines.size()) {
const std::string& field = lines[index++];
if (!field.empty() && field.front() == '\t') {
line.text = field.substr(1);
break;
}
const size_t separator = field.find(' ');
const std::string_view key(field.data(), separator == std::string::npos ? field.size() : separator);
const std::string value = separator == std::string::npos ? std::string{} : field.substr(separator + 1);
if (key == "author") line.author = value;
else if (key == "author-mail") {
line.email = value;
if (line.email.size() >= 2 && line.email.front() == '<' && line.email.back() == '>')
line.email = line.email.substr(1, line.email.size() - 2);
}
else if (key == "author-time") {
try { author_time = static_cast<std::time_t>(std::stoll(value)); }
catch (...) { author_time = 0; }
} else if (key == "summary") line.summary = value;
}
if (author_time != 0) {
std::tm date{};
#ifdef _WIN32
localtime_s(&date, &author_time);
#else
localtime_r(&author_time, &date);
#endif
std::ostringstream formatted;
formatted << std::put_time(&date, "%Y-%m-%d");
line.date = formatted.str();
}
line.show_attribution = line.hash != previous_hash;
previous_hash = line.hash;
blame_lines_.push_back(std::move(line));
}
}
void DiffViewer::parseHistory(const std::string& text) {
history_entries_.clear();
for (const std::string& line : splitLines(text)) {
if (line.empty()) continue;
HistoryEntry entry;
size_t cursor = 0;
auto consume_field = [&](std::string& out) {
const size_t separator = line.find('\x1f', cursor);
if (separator == std::string::npos) {
out = line.substr(cursor);
cursor = line.size();
return;
}
out = line.substr(cursor, separator - cursor);
cursor = separator + 1;
};
consume_field(entry.hash);
consume_field(entry.date);
consume_field(entry.author);
consume_field(entry.summary);
if (!entry.hash.empty() || !entry.summary.empty()) history_entries_.push_back(std::move(entry));
}
}
void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::string& notice) {
std::vector<std::string> arguments{"diff", "--no-ext-diff", "--no-color", "--unified=3"};
if (staged_) arguments.push_back("--cached");
std::vector<std::string> arguments;
if (commit_id_.empty()) {
arguments = {"diff", "--no-ext-diff", "--no-color", "--unified=3"};
if (staged_) arguments.push_back("--cached");
} else {
arguments = {"show", "--first-parent", "--format=", "--no-ext-diff", "--no-color",
"--unified=3", commit_id_};
}
arguments.insert(arguments.end(), {"--", path_});
std::string output;
std::string error;
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
parseDiff(output);
if (hunks_.empty()) {
if (hunks_.empty() && commit_id_.empty()) {
const auto file = std::find_if(repository.working_files.begin(), repository.working_files.end(),
[this](const WorkingFile& item) { return item.path == path_; });
if (file != repository.working_files.end() && file->kind == FileChangeKind::added && !staged_) {
@@ -149,7 +644,7 @@ void DiffViewer::reload(RepositoryView& repository, GitManager& manager, std::st
}
file_lines_.clear();
blame_lines_.clear();
history_lines_.clear();
history_entries_.clear();
if (mode_ != Mode::diff) loadSupplement(repository, manager, mode_, notice);
}
@@ -158,7 +653,10 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
std::string output;
std::string error;
if (mode == Mode::file) {
if (staged_) {
if (!commit_id_.empty()) {
if (!manager.captureGit(repository, {"show", commit_id_ + ":" + path_}, output, error))
notice = error;
} else if (staged_) {
if (!manager.captureGit(repository, {"show", ":" + path_}, output, error)) notice = error;
} else {
std::ifstream stream(std::filesystem::path(repository.path) / path_, std::ios::binary);
@@ -168,26 +666,35 @@ void DiffViewer::loadSupplement(RepositoryView& repository, GitManager& manager,
}
file_lines_ = splitLines(output);
} else if (mode == Mode::blame) {
if (!manager.captureGit(repository, {"blame", "--date=short", "--", path_}, output, error)) notice = error;
blame_lines_ = splitLines(output);
std::vector<std::string> arguments{"blame", "--line-porcelain"};
if (!commit_id_.empty()) arguments.push_back(commit_id_);
arguments.insert(arguments.end(), {"--", path_});
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
parseBlame(output);
} else if (mode == Mode::history) {
if (!manager.captureGit(repository,
{"log", "--follow", "--date=short", "--pretty=format:%h %ad %an %s", "--", path_},
output, error)) notice = error;
history_lines_ = splitLines(output);
std::vector<std::string> arguments{
"log", "--follow", "--date=short", "--pretty=format:%h%x1f%ad%x1f%an%x1f%s"};
if (!commit_id_.empty()) arguments.push_back(commit_id_);
arguments.insert(arguments.end(), {"--", path_});
if (!manager.captureGit(repository, arguments, output, error)) notice = error;
parseHistory(output);
}
}
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float scale, std::string& notice) {
void DiffViewer::draw(RepositoryView& repository, GitManager& manager, AvatarCache* avatars,
float scale, ImFont* code_font, std::string& notice) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {scaled(8, scale), scaled(5, scale)});
ImGui::BeginChild("diff_viewer", {-1, -1}, ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_FA_PEN);
ImGui::TextColored(ImVec4(0.94f, 0.66f, 0.25f, 1), ICON_TB_PEN);
ImGui::SameLine(0, scaled(7, scale));
ImGui::TextUnformatted(path_.c_str());
ImGui::SameLine(std::max(scaled(240, scale), ImGui::GetWindowWidth() - scaled(455, scale)));
if (compactButton(staged_ ? "Staged" : "Unstaged", true)) {}
const bool historical = !commit_id_.empty();
const std::string source_label = historical ? "Commit " + commit_id_.substr(0, 8)
: staged_ ? "Staged" : "Unstaged";
if (compactButton(source_label.c_str(), true)) {}
ImGui::SameLine();
if (compactButton("File View", mode_ == Mode::file)) {
mode_ = Mode::file; loadSupplement(repository, manager, mode_, notice);
@@ -202,63 +709,82 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
if (compactButton("History", mode_ == Mode::history)) {
mode_ = Mode::history; loadSupplement(repository, manager, mode_, notice);
}
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1));
if (compactButton(staged_ ? "Unstage File" : "Stage File")) {
const bool changed = staged_ ? manager.unstageFile(repository, path_, notice)
: manager.stageFile(repository, path_, notice);
if (changed) { staged_ = !staged_; reload(repository, manager, notice); }
if (!historical) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.43f, 0.90f, 0.51f, 1));
if (compactButton(staged_ ? "Unstage File" : "Stage File")) {
const bool changed = staged_ ? manager.unstageFile(repository, path_, notice)
: manager.stageFile(repository, path_, notice);
if (changed) { staged_ = !staged_; reload(repository, manager, notice); }
}
ImGui::PopStyleColor();
}
ImGui::PopStyleColor();
ImGui::SameLine();
if (compactButton(ICON_FA_XMARK)) close();
if (compactButton(ICON_TB_XMARK)) close();
ImGui::Separator();
if (!path_.empty()) {
if (compactButton(ICON_FA_PEN " Edit This File")) {
std::string error;
if (!izo::OpenPath(std::filesystem::path(repository.path) / path_, &error)) notice = error;
}
ImGui::SameLine(ImGui::GetWindowWidth() - scaled(116, scale));
ImGui::SetCursorPosX(ImGui::GetWindowWidth() - scaled(116, scale));
ImGui::TextDisabled("UTF-8");
if (!staged_) {
if (!historical && !staged_) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
if (compactButton(ICON_FA_TRASH_CAN)) {
if (compactButton(ICON_TB_TRASH_CAN)) {
if (manager.discardFile(repository, path_, notice)) close();
}
ImGui::PopStyleColor();
}
ImGui::Separator();
ImGui::Separator();
}
ImGui::BeginChild("diff_content", {-1, -1}, ImGuiChildFlags_None,
ImGuiWindowFlags_HorizontalScrollbar);
const bool show_minimap = mode_ == Mode::diff || mode_ == Mode::file || mode_ == Mode::blame || mode_ == Mode::history;
const float minimap_width = scaled(56.0f, scale);
float main_scroll_y = 0.0f;
float main_window_height = 0.0f;
float rendered_content_height = 0.0f;
std::vector<MinimapEntry> minimap_entries;
if (show_minimap) {
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
if (mode_ == Mode::diff) {
for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) {
if (hunk_index > 0) {
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
}
minimap_entries.push_back({hunks_[hunk_index].header.size(), IM_COL32(128, 133, 141, 255)});
for (const Line& line : hunks_[hunk_index].lines) {
const ImU32 color = line.kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
line.kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) :
IM_COL32(112, 118, 128, 255);
minimap_entries.push_back({line.text.size() + 1, color});
}
minimap_entries.push_back({0, IM_COL32(0, 0, 0, 0)});
}
} else if (mode_ == Mode::file) {
minimap_entries.reserve(file_lines_.size());
for (const std::string& line : file_lines_)
minimap_entries.push_back({line.size(), IM_COL32(112, 118, 128, 255)});
} else if (mode_ == Mode::blame) {
minimap_entries.reserve(blame_lines_.size());
for (const BlameLine& line : blame_lines_)
minimap_entries.push_back({line.text.size(), blameColor(line.hash, line.show_attribution ? 200 : 125)});
} else {
minimap_entries.reserve(history_entries_.size());
for (const HistoryEntry& entry : history_entries_)
minimap_entries.push_back({entry.summary.size() + entry.author.size(), IM_COL32(112, 118, 128, 255)});
}
}
ImGui::BeginChild("diff_content_main",
show_minimap ? ImVec2{-minimap_width - scaled(6.0f, scale), -1} : ImVec2{-1, -1},
ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar);
const bool use_code_font = code_font && mode_ != Mode::history;
if (use_code_font) ImGui::PushFont(code_font, 0.0f);
const float row_height = scaled(21, scale);
const float number_width = scaled(48, scale);
auto draw_line = [&](const std::string& text, int old_number, int new_number, LineKind kind) {
ImGui::InvisibleButton("##line", {std::max(ImGui::GetContentRegionAvail().x, scaled(900, scale)), row_height});
const ImVec2 minimum = ImGui::GetItemRectMin();
const ImVec2 maximum = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
if (kind == LineKind::added) draw->AddRectFilled(minimum, maximum, IM_COL32(31, 65, 43, 225));
else if (kind == LineKind::removed) draw->AddRectFilled(minimum, maximum, IM_COL32(70, 38, 40, 225));
draw->AddRectFilled(minimum, {minimum.x + number_width * 2, maximum.y}, IM_COL32(31, 34, 40, 255));
char old_buffer[16]{};
char new_buffer[16]{};
if (old_number) std::snprintf(old_buffer, sizeof(old_buffer), "%d", old_number);
if (new_number) std::snprintf(new_buffer, sizeof(new_buffer), "%d", new_number);
draw->AddText({minimum.x + scaled(5, scale), minimum.y + scaled(2, scale)},
IM_COL32(158, 164, 174, 255), old_buffer);
draw->AddText({minimum.x + number_width + scaled(5, scale), minimum.y + scaled(2, scale)},
IM_COL32(158, 164, 174, 255), new_buffer);
const char marker = kind == LineKind::added ? '+' : kind == LineKind::removed ? '-' : ' ';
char marker_text[2]{marker, 0};
draw->AddText({minimum.x + number_width * 2 + scaled(5, scale), minimum.y + scaled(2, scale)},
kind == LineKind::added ? IM_COL32(87, 190, 112, 255) :
kind == LineKind::removed ? IM_COL32(220, 97, 97, 255) : IM_COL32(148, 154, 164, 255), marker_text);
draw->AddText({minimum.x + number_width * 2 + scaled(22, scale), minimum.y + scaled(2, scale)},
syntaxColor(text), text.c_str());
};
const float content_width = std::max(ImGui::GetContentRegionAvail().x, scaled(200.0f, scale));
const SyntaxLanguage language = languageForPath(path_);
// Keep the first source row clear of the toolbar and aligned with a normal row inset.
ImGui::Dummy({0.0f, row_height});
if (mode_ == Mode::diff) {
if (hunks_.empty()) ImGui::TextDisabled("No textual diff is available for this file.");
@@ -267,12 +793,19 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
enum class HunkAction { none, stage, discard, unstage };
HunkAction pending_action = HunkAction::none;
for (size_t hunk_index = 0; hunk_index < hunks_.size(); ++hunk_index) {
SyntaxState old_syntax;
SyntaxState new_syntax;
ImGui::PushID(static_cast<int>(hunk_index));
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.56f, 0.70f, 0.90f, 1));
if (hunk_index > 0) {
ImGui::Dummy({0.0f, scaled(11.0f, scale)});
ImGui::Separator();
ImGui::Dummy({0.0f, scaled(8.0f, scale)});
}
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.62f, 0.64f, 0.68f, 1.0f));
ImGui::TextUnformatted(hunks_[hunk_index].header.c_str());
ImGui::PopStyleColor();
ImGui::SameLine(std::max(scaled(220, scale), ImGui::GetWindowWidth() - scaled(205, scale)));
if (!staged_) {
if (!historical && !staged_) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.94f, 0.44f, 0.44f, 1));
if (ImGui::SmallButton("Discard Hunk")) {
pending_hunk = static_cast<int>(hunk_index);
@@ -286,13 +819,27 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
pending_action = HunkAction::stage;
}
ImGui::PopStyleColor();
} else if (ImGui::SmallButton("Unstage Hunk")) {
} else if (!historical && ImGui::SmallButton("Unstage Hunk")) {
pending_hunk = static_cast<int>(hunk_index);
pending_action = HunkAction::unstage;
}
ImGui::Separator();
ImGui::Dummy({0.0f, scaled(4.0f, scale)});
const float number_column = scaled(44.0f, scale);
const float code_gutter = number_column * 2.0f + scaled(14.0f, scale);
for (const Line& line : hunks_[hunk_index].lines) {
ImGui::PushID(line_id++);
draw_line(line.text, line.old_number, line.new_number, line.kind);
const ImVec2 line_minimum = ImGui::GetCursorScreenPos();
const ImU32 background = line.kind == LineKind::added ? IM_COL32(30, 68, 46, 170) :
line.kind == LineKind::removed ? IM_COL32(86, 38, 42, 170) : IM_COL32(0, 0, 0, 0);
drawCodeLine(line.text, language,
line.kind == LineKind::removed ? old_syntax : new_syntax,
scale, background, code_gutter, content_width);
const float text_y = line_minimum.y + scaled(2.0f, scale);
drawCodeLineNumber(line.old_number, line_minimum.x + scaled(6.0f, scale), text_y,
IM_COL32(126, 132, 142, 255));
drawCodeLineNumber(line.new_number, line_minimum.x + number_column + scaled(6.0f, scale), text_y,
IM_COL32(126, 132, 142, 255));
ImGui::PopID();
}
ImGui::PopID();
@@ -303,18 +850,121 @@ void DiffViewer::draw(RepositoryView& repository, GitManager& manager, float sca
if (manager.applyPatch(repository, hunks_[pending_hunk].patch, cached, reverse, notice))
reload(repository, manager, notice);
}
} else {
const std::vector<std::string>* lines = mode_ == Mode::file ? &file_lines_ :
mode_ == Mode::blame ? &blame_lines_ : &history_lines_;
for (size_t index = 0; index < lines->size(); ++index) {
ImGui::PushID(static_cast<int>(index));
draw_line((*lines)[index], mode_ == Mode::file ? static_cast<int>(index + 1) : 0,
mode_ == Mode::file ? static_cast<int>(index + 1) : 0, LineKind::context);
ImGui::PopID();
} else if (mode_ == Mode::blame) {
if (blame_lines_.empty()) {
ImGui::TextDisabled("No blame data is available for this file.");
} else {
SyntaxState syntax;
const float info_width = scaled(270.0f, scale);
const float line_number_width = scaled(48.0f, scale);
const float code_gutter = info_width + line_number_width + scaled(14.0f, scale);
for (size_t index = 0; index < blame_lines_.size(); ++index) {
ImGui::PushID(static_cast<int>(index));
const BlameLine& line = blame_lines_[index];
const ImVec2 line_minimum = ImGui::GetCursorScreenPos();
const ImU32 accent = blameColor(line.hash, line.show_attribution ? 255 : 190);
const ImU32 background = line.show_attribution ? IM_COL32(39, 42, 50, 150) : IM_COL32(31, 34, 40, 105);
const float blame_row_height = line.show_attribution ? scaled(28.0f, scale) : scaled(21.0f, scale);
drawCodeLine(line.text, language, syntax, scale, background, code_gutter, content_width,
blame_row_height);
ImDrawList* draw = ImGui::GetWindowDrawList();
const ImVec2 line_maximum = ImGui::GetItemRectMax();
draw->AddRectFilled({line_minimum.x, line_minimum.y},
{line_minimum.x + scaled(3.0f, scale), line_maximum.y}, accent);
drawCodeLineNumber(line.line_number, line_minimum.x + info_width + scaled(6.0f, scale),
line_minimum.y + scaled(2.0f, scale), IM_COL32(126, 132, 142, 255));
if (line.show_attribution) {
const float avatar_size = scaled(16.0f, scale);
const ImVec2 avatar_min{line_minimum.x + scaled(8.0f, scale), line_minimum.y + scaled(2.0f, scale)};
const unsigned int avatar_texture = avatars ? avatars->textureFor(line.email) : 0;
if (avatar_texture) {
draw->AddImageRounded(ImTextureRef(static_cast<ImTextureID>(avatar_texture)),
avatar_min, {avatar_min.x + avatar_size, avatar_min.y + avatar_size},
{0, 0}, {1, 1}, IM_COL32_WHITE, scaled(3.0f, scale));
} else {
draw->AddRectFilled(avatar_min, {avatar_min.x + avatar_size, avatar_min.y + avatar_size},
accent, scaled(3.0f, scale));
}
const float info_x = avatar_min.x + avatar_size + scaled(8.0f, scale);
const std::string author = line.author.empty() ? "Unknown author" : line.author;
std::string summary = line.summary.empty() ? "(no summary)" : line.summary;
if (!line.date.empty()) summary += " " + line.date;
draw->AddText({info_x, line_minimum.y + scaled(1.0f, scale)},
IM_COL32(216, 220, 226, 255), author.c_str());
draw->AddText({info_x, line_minimum.y + scaled(10.0f, scale)},
IM_COL32(134, 140, 151, 255), summary.c_str());
} else {
draw->AddText({line_minimum.x + scaled(16.0f, scale), line_minimum.y + scaled(2.0f, scale)},
IM_COL32(92, 99, 110, 255), "...");
}
ImGui::PopID();
}
}
} else if (mode_ == Mode::file) {
const std::vector<std::string>* lines = &file_lines_;
if (mode_ == Mode::file) {
SyntaxState syntax;
const float code_gutter = scaled(52.0f, scale);
for (size_t index = 0; index < lines->size(); ++index) {
ImGui::PushID(static_cast<int>(index));
const ImVec2 line_minimum = ImGui::GetCursorScreenPos();
drawCodeLine((*lines)[index], language, syntax, scale, IM_COL32(0, 0, 0, 0),
code_gutter, content_width);
drawCodeLineNumber(static_cast<int>(index + 1), line_minimum.x + scaled(6.0f, scale),
line_minimum.y + scaled(2.0f, scale), IM_COL32(126, 132, 142, 255));
ImGui::PopID();
}
}
if (lines->empty()) ImGui::TextDisabled("No data is available for this view.");
} else {
if (history_entries_.empty()) {
ImGui::TextDisabled("No history data is available for this file.");
} else {
for (size_t index = 0; index < history_entries_.size(); ++index) {
ImGui::PushID(static_cast<int>(index));
const HistoryEntry& entry = history_entries_[index];
const ImVec2 row_min = ImGui::GetCursorScreenPos();
const float history_row_height = scaled(36.0f, scale);
ImGui::InvisibleButton("##history_row", {content_width, history_row_height});
const ImVec2 row_max = ImGui::GetItemRectMax();
ImDrawList* draw = ImGui::GetWindowDrawList();
const bool hovered = ImGui::IsItemHovered();
draw->AddRectFilled(row_min, row_max,
hovered ? IM_COL32(49, 53, 61, 180) : IM_COL32(37, 40, 47, 130), scaled(4.0f, scale));
draw->AddRectFilled(row_min, {row_min.x + scaled(3.0f, scale), row_max.y},
IM_COL32(23, 181, 204, 220), scaled(3.0f, scale));
const std::string hash_text = entry.hash.empty() ? "-" : entry.hash;
draw->AddText({row_min.x + scaled(10.0f, scale), row_min.y + scaled(4.0f, scale)},
IM_COL32(96, 184, 235, 255), hash_text.c_str());
const std::string author_date = entry.author +
(entry.date.empty() ? std::string{} : std::string(" ") + entry.date);
draw->AddText({row_min.x + scaled(78.0f, scale), row_min.y + scaled(4.0f, scale)},
IM_COL32(188, 192, 198, 255), author_date.c_str());
const std::string summary = entry.summary.empty() ? "(no summary)" : entry.summary;
draw->AddText({row_min.x + scaled(10.0f, scale), row_min.y + scaled(18.0f, scale)},
IM_COL32(219, 223, 229, 255), summary.c_str());
ImGui::PopID();
}
}
}
main_scroll_y = ImGui::GetScrollY();
main_window_height = ImGui::GetWindowHeight();
rendered_content_height = ImGui::GetCursorPosY();
if (use_code_font) ImGui::PopFont();
ImGui::EndChild();
if (show_minimap) {
ImGui::SameLine(0, scaled(6.0f, scale));
const float minimap_line_height = scaled(2.0f, scale);
const float minimap_height = std::min(main_window_height,
std::max(scaled(36.0f, scale), static_cast<float>(minimap_entries.size()) * minimap_line_height + scaled(8.0f, scale)));
ImGui::BeginChild("diff_content_minimap", {minimap_width, minimap_height}, ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
drawMinimap(minimap_entries, scale, rendered_content_height, main_scroll_y, main_window_height);
ImGui::EndChild();
}
ImGui::EndChild();
ImGui::PopStyleVar();
}

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,36 @@ 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_;
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,98 @@
#include "managers/avatar_cache.h"
#include "models/repository.h"
#include <IconsFontAwesome6.h>
#include <IconsTabler.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
namespace {
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;
}
if (std::isfinite(detour_x)) {
if (std::abs(detour_x - child.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
} else if (std::abs(detour_x - parent.x) < 0.5f * scale) {
drawRoundedPolyline(draw, std::array{child, ImVec2{detour_x, child.y}, parent},
7.0f * scale, color, thickness);
} else {
drawRoundedPolyline(draw,
std::array{child, ImVec2{detour_x, child.y}, ImVec2{detour_x, parent.y}, parent},
7.0f * scale, color, thickness);
}
return;
}
if (std::abs(horizontal) < 0.5f * scale) {
draw->AddLine(child, parent, color, thickness);
return;
}
// Bends remain anchored to commit rows, but use a compact quarter-round turn.
if (route_slot == 0)
drawRoundedPolyline(draw, std::array{child, ImVec2{child.x, parent.y}, parent},
7.0f * scale, color, thickness);
else
drawRoundedPolyline(draw, std::array{child, ImVec2{parent.x, child.y}, parent},
7.0f * scale, color, thickness);
}
} // namespace
ImU32 GraphRenderer::laneColor(int lane, int alpha) {
static constexpr ImVec4 colors[] = {
@@ -25,35 +113,47 @@ ImU32 GraphRenderer::laneColor(int lane, int alpha) {
float GraphRenderer::requiredWidth(const std::vector<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 * 22.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(22.0f);
const float x = origin.x + px(17.0f) + lane_spacing * commit.lane;
const float y = origin.y + content_height * 0.5f;
const float cell_right = origin.x + ImGui::GetContentRegionAvail().x;
const float row_clip_padding = ImGui::GetStyle().CellPadding.y + px(1.0f);
// GitKraken-style lane ribbon: a quiet tint carries the branch color through
// the rest of the graph column while the far edge remains crisply identifiable.
// Start the lane ribbon at the profile circle's centerline. The opaque node backing
// masks its left edge, while the vertical inset keeps neighboring row tints separate.
const float ribbon_left = std::min(x, cell_right);
const float ribbon_top = origin.y + px(1.0f);
const float ribbon_bottom = origin.y + content_height - px(1.0f);
draw->AddRectFilled(
{x, origin.y - row_clip_padding},
{cell_right, origin.y + content_height + row_clip_padding},
laneColor(commit.lane, 38));
{ribbon_left, ribbon_top},
{cell_right, ribbon_bottom},
laneColor(commit.graph_color, 38));
draw->AddLine(
{cell_right - px(1.0f), origin.y - row_clip_padding},
{cell_right - px(1.0f), origin.y + content_height + row_clip_padding},
laneColor(commit.lane, 220), px(2.0f));
if (!commit.refs.empty())
draw->AddLine({origin.x - ImGui::GetStyle().CellPadding.x, y}, {x, y},
laneColor(commit.lane, 150), px(1.0f));
{cell_right - px(1.0f), ribbon_top},
{cell_right - px(1.0f), ribbon_bottom},
laneColor(commit.graph_color, 215), px(2.0f));
if (ref_connector_start) {
const ImVec2 end{x, y};
const float turn_x = end.x - px(8.0f);
draw->PushClipRectFullScreen();
drawRoundedPolyline(draw,
std::array{*ref_connector_start, ImVec2{turn_x, ref_connector_start->y},
ImVec2{turn_x, end.y}, end},
px(5.0f), laneColor(commit.graph_color, 190), px(1.8f));
draw->PopClipRect();
}
std::vector<float> row_offsets(row_heights.size() + 1, 0.0f);
for (size_t index = 0; index < row_heights.size(); ++index)
@@ -72,49 +172,81 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
for (int child_row = 0; child_row < static_cast<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)]) {
const auto& child_parents = parent_rows[static_cast<size_t>(child_row)];
for (size_t parent_index = 0; parent_index < child_parents.size(); ++parent_index) {
const int parent_row = child_parents[parent_index];
if (parent_row <= child_row || row < child_row || row > parent_row ||
row_heights[static_cast<size_t>(parent_row)] <= 0.0f) continue;
const CommitInfo& parent = commits[static_cast<size_t>(parent_row)];
const float child_x = origin.x + px(15.0f) + lane_spacing * child.lane;
const float parent_x = origin.x + px(15.0f) + lane_spacing * parent.lane;
const float child_x = origin.x + px(17.0f) + lane_spacing * child.lane;
const float parent_x = origin.x + px(17.0f) + lane_spacing * parent.lane;
const float child_y = center_y(child_row);
const float parent_y = center_y(parent_row);
// An edge belongs to the branch it leaves, including the curved transition
// into a parent lane, so its color stays consistent with the child node/ref chip.
const ImU32 color = laneColor(child.lane, 225);
if (child.lane == parent.lane) {
draw->AddLine({child_x, child_y}, {parent_x, parent_y}, color, px(1.8f));
continue;
const int preferred_lane = parent_index == 0 ? child.lane : parent.lane;
const auto lane_is_blocked = [&](int candidate_lane) {
for (int intermediate = child_row + 1; intermediate < parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f &&
commits[static_cast<size_t>(intermediate)].lane == candidate_lane)
return true;
}
return false;
};
float detour_x = std::numeric_limits<float>::quiet_NaN();
if (lane_is_blocked(preferred_lane)) {
int maximum_lane = 0;
for (int intermediate = child_row; intermediate <= parent_row; ++intermediate) {
if (row_heights[static_cast<size_t>(intermediate)] > 0.0f)
maximum_lane = std::max(maximum_lane,
commits[static_cast<size_t>(intermediate)].lane);
}
int detour_lane = maximum_lane + 1;
for (int distance = 1; distance <= maximum_lane + 1; ++distance) {
const int left = preferred_lane - distance;
const int right = preferred_lane + distance;
if (left >= 0 && !lane_is_blocked(left)) {
detour_lane = left;
break;
}
if (right <= maximum_lane && !lane_is_blocked(right)) {
detour_lane = right;
break;
}
}
detour_x = origin.x + px(17.0f) + lane_spacing * detour_lane;
}
const float bend_height = std::min(parent_y - child_y, px(24.0f));
const float curve_end_y = child_y + bend_height;
draw->AddBezierCubic(
{child_x, child_y},
{child_x, child_y + bend_height * 0.45f},
{parent_x, child_y + bend_height * 0.55f},
{parent_x, curve_end_y},
color, px(1.8f));
if (curve_end_y < parent_y)
draw->AddLine({parent_x, curve_end_y}, {parent_x, parent_y}, color, px(1.8f));
// Colors are lane-position based, so edges use the visible lane slot they route through.
const int edge_color = std::isfinite(detour_x)
? static_cast<int>(std::lround((detour_x - (origin.x + px(17.0f))) / lane_spacing))
: preferred_lane;
drawOrthogonalEdge(draw, {child_x, child_y}, {parent_x, parent_y},
laneColor(edge_color, 235), scale_, static_cast<int>(parent_index), detour_x);
}
}
draw->PopClipRect();
const ImU32 lane_color = laneColor(commit.lane);
const ImU32 lane_color = laneColor(commit.graph_color);
const auto show_identity_tooltip = [&](float hit_radius) {
if (!ImGui::IsWindowHovered()) return;
const ImVec2 mouse = ImGui::GetIO().MousePos;
const float delta_x = mouse.x - x;
const float delta_y = mouse.y - y;
if (delta_x * delta_x + delta_y * delta_y > hit_radius * hit_radius) return;
if (commit.email.empty()) ImGui::SetTooltip("%s", commit.author.c_str());
else ImGui::SetTooltip("%s <%s>", commit.author.c_str(), commit.email.c_str());
};
if (commit.parents > 1) {
draw->AddCircleFilled({x, y}, px(4.5f), lane_color);
draw->AddCircle({x, y}, px(6.0f), laneColor(commit.lane, 110), 0, px(1.0f));
draw->AddCircleFilled({x, y}, px(6.0f), lane_color);
draw->AddCircle({x, y}, px(7.5f), laneColor(commit.graph_color, 130), 0, px(1.2f));
show_identity_tooltip(px(9.0f));
ImGui::Dummy({0.0f, content_height});
return;
}
const float radius = px(8.4f);
const float radius = px(9.6f);
// The opaque backing masks every lane segment before the avatar is painted.
draw->AddCircleFilled({x, y}, radius + px(2.2f), IM_COL32(19, 24, 31, 255));
draw->AddCircle({x, y}, radius + px(1.0f), lane_color, 0, px(2.0f));
draw->AddCircleFilled({x, y}, radius + px(1.8f), IM_COL32(19, 24, 31, 255));
draw->AddCircle({x, y}, radius + px(0.8f), lane_color, 0, px(2.0f));
draw->AddCircleFilled({x, y}, radius - px(1.0f), IM_COL32(232, 238, 242, 255));
const unsigned int texture = avatars ? avatars->textureFor(commit.email) : 0;
@@ -124,8 +256,9 @@ void GraphRenderer::drawRow(int row, const CommitInfo& commit,
{x + radius - px(1.5f), y + radius - px(1.5f)},
{0, 0}, {1, 1}, IM_COL32_WHITE, radius);
} else {
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_FA_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_FA_USER);
const ImVec2 icon_size = ImGui::CalcTextSize(ICON_TB_USER);
draw->AddText({x - icon_size.x * 0.5f, y - icon_size.y * 0.5f}, lane_color, ICON_TB_USER);
}
show_identity_tooltip(radius + px(2.0f));
ImGui::Dummy({0.0f, content_height});
}

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

2
vendor/iZo vendored

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

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

@@ -0,0 +1,59 @@
#pragma once
// Tabler Icons 3.44.0 outline webfont glyphs used by Gitree.
#define ICON_MIN_TB 0xEA06
#define ICON_MAX_TB 0xFAF7
#define ICON_TB_ANGLE_RIGHT "\xee\xa9\xa1"
#define ICON_TB_ARROW_DOWN "\xee\xa8\x96"
#define ICON_TB_ARROW_DOWN_A_Z "\xee\xbc\x98"
#define ICON_TB_ARROW_DOWN_Z_A "\xee\xbc\x9a"
#define ICON_TB_ARROW_RIGHT_ARROW_LEFT "\xef\x87\xb4"
#define ICON_TB_ARROW_UP "\xee\xa8\xa5"
#define ICON_TB_ARROW_UP_RIGHT_FROM_SQUARE "\xee\xaa\x99"
#define ICON_TB_BARS "\xee\xb1\x82"
#define ICON_TB_BOX_ARCHIVE "\xee\xa8\x8b"
#define ICON_TB_BOX_OPEN "\xef\x81\xba"
#define ICON_TB_CARET_DOWN "\xee\xad\x9d"
#define ICON_TB_CHECK "\xee\xa9\x9e"
#define ICON_TB_CHEVRON_DOWN "\xee\xa9\x9f"
#define ICON_TB_CHEVRON_RIGHT "\xee\xa9\xa1"
#define ICON_TB_CIRCLE_CHEVRON_LEFT "\xef\x98\xa3"
#define ICON_TB_CIRCLE_CHEVRON_RIGHT "\xef\x98\xa4"
#define ICON_TB_CIRCLE_DOT "\xee\xbe\xb1"
#define ICON_TB_CIRCLE_NODES "\xee\xaa\xb5"
#define ICON_TB_CLOCK_ROTATE_LEFT "\xee\xaf\xaa"
#define ICON_TB_CLOUD "\xee\xa9\xb6"
#define ICON_TB_CODE "\xee\xa9\xb7"
#define ICON_TB_CODE_BRANCH "\xee\xaa\xb2"
#define ICON_TB_COMPUTER "\xee\xaa\x89"
#define ICON_TB_COPY "\xee\xa9\xba"
#define ICON_TB_CUBES "\xee\xb8\x97"
#define ICON_TB_DESKTOP "\xee\xaa\x89"
#define ICON_TB_DEVICE_LAPTOP "\xee\xad\xa4"
#define ICON_TB_DOWNLOAD "\xee\xaa\x96"
#define ICON_TB_EYE "\xee\xaa\x9a"
#define ICON_TB_FILE "\xee\xaa\xa4"
#define ICON_TB_FOLDER "\xee\xaa\xad"
#define ICON_TB_FOLDER_OPEN "\xef\xab\xb7"
#define ICON_TB_FOLDER_TREE "\xee\xba\x9d"
#define ICON_TB_GLOBE "\xee\xad\x94"
#define ICON_TB_JET_FIGHTER_UP "\xef\x87\xad"
#define ICON_TB_LAYERS_LINKED "\xee\xba\xa1"
#define ICON_TB_MAGNIFYING_GLASS "\xee\xac\x9c"
#define ICON_TB_MINUS "\xee\xab\xb2"
#define ICON_TB_PEN "\xee\xac\x84"
#define ICON_TB_PLUS "\xee\xac\x8b"
#define ICON_TB_ROBOT "\xef\x80\x8b"
#define ICON_TB_ROTATE_LEFT "\xee\xac\x96"
#define ICON_TB_ROTATE_RIGHT "\xee\xac\x95"
#define ICON_TB_SERVER "\xee\xac\x9f"
#define ICON_TB_TAG "\xee\xbe\x86"
#define ICON_TB_TERMINAL "\xee\xaf\xaf"
#define ICON_TB_TRASH_CAN "\xee\xad\x81"
#define ICON_TB_TREE "\xee\xbc\x81"
#define ICON_TB_TRIANGLE_EXCLAMATION "\xee\xa8\x86"
#define ICON_TB_UPLOAD "\xee\xad\x87"
#define ICON_TB_USER "\xee\xad\x8d"
#define ICON_TB_WINDOW_MAXIMIZE "\xee\xab\xaa"
#define ICON_TB_XMARK "\xee\xad\x95"

2
vendor/imgui vendored

2
vendor/libgit2 vendored